diff --git a/build.gradle b/build.gradle index 80d40ea4c..fe030008a 100644 --- a/build.gradle +++ b/build.gradle @@ -90,7 +90,7 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:testcontainers:1.21.4' testImplementation 'org.testcontainers:junit-jupiter:1.19.3' testImplementation 'org.testcontainers:mysql' testImplementation 'org.testcontainers:localstack' diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java new file mode 100644 index 000000000..7f4dfbc98 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java @@ -0,0 +1,397 @@ +package in.koreatech.koin.domain.callvan.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; +import static in.koreatech.koin.global.code.ApiResponseCode.*; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostStatusFilter; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.auth.UserId; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.global.code.ApiResponseCodes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Callvan: 콜밴", description = "콜밴 정보를 관리한다") +@RequestMapping("/callvan") +public interface CallvanApi { + + @ApiResponseCodes({ + CREATED, + NOT_FOUND_USER, + INVALID_REQUEST_BODY, + INVALID_CUSTOM_LOCATION_NAME + }) + @Operation(summary = "콜밴 게시글 생성", description = """ + ### 콜밴 게시글 생성 API + 새로운 콜밴 모집 게시글을 생성하고, 자동으로 관련 채팅방과 작성자를 참여자로 등록합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + + #### 요청 필드 설명 + - `departure_type`: 출발지 종류 (FRONT_GATE(정문), BACK_GATE(후문), TENNIS_COURT(테니스장), DORMITORY_MAIN(본관동), DORMITORY_SUB(별관동), TERMINAL(천안 터미널), STATION(천안역), ASAN_STATION(천안아산역), CUSTOM) + - `departure_custom_name`: `departure_type`이 `CUSTOM`일 경우에만 사용자의 입력을 전달합니다. `CUSTOM`이 아닐 때 값을 전달(공백 포함)하거나 `CUSTOM`일때 값을 전달하지 않으면(null, 공백) 비즈니스 예외가 발생합니다. + - `arrival_type`: 도착지 종류 (출발지와 동일한 옵션) + - `arrival_custom_name`: `arrival_type`이 `CUSTOM`일 경우에만 사용자의 입력을 전달합니다. `CUSTOM`이 아닐 때 값을 전달(공백 포함)하거나 `CUSTOM`일때 값을 전달하지 않으면(null, 공백) 비즈니스 예외가 발생합니다. + - `departure_date`: 출발 날짜 (`yyyy-MM-dd` 형식) + - `departure_time`: 출발 시각 (`HH:mm` 형식, 24시간제) + - `max_participants`: 모집할 최대 인원 (2~11명 사이만 가능) + + #### 비즈니스 로직 + 1. `CUSTOM`이 아닌 장소 타입 선택 시, `custom_name` 필드는 비워두어야(null) 하며 서버에서 해당 타입의 명칭으로 자동 채워집니다. + 2. `CUSTOM`장소 타입 선택 시, `custom_name` 필드가 비워져있으면(null, blank) 오류가 발생합니다 + 3. 게시글 생성 시 제목은 `{출발지} -> {도착지}` 형식으로 자동 생성됩니다. + 4. 생성과 동시에 해당 게시글 전용 채팅방(`CallvanChatRoom`)이 생성됩니다. + 5. 생성자는 자동으로 해당 게시글의 참여자(`CallvanParticipant`)로 등록되며, 역할은 `AUTHOR`로 설정됩니다. + """) + @PostMapping + ResponseEntity createCallvanPost( + @RequestBody @Valid CallvanPostCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + OK, + NOT_FOUND_USER, + UNAUTHORIZED_USER + }) + @Operation(summary = "콜밴 게시글 목록 조회", description = """ + ### 콜밴 게시글 목록 조회 API + 여러 조건에 따라 콜밴 모집 게시글 목록을 조회합니다. 페이징과 정렬 기능이 포함되어 있습니다. + + #### 요청 필드 설명 + - `author`: 작성자 필터 (`ALL`(전체), `MY`(내 게시글)). `MY` 선택 시 인증이 필수입니다. + - `statuses`: 모집 상태 필터 (`RECRUITING`, `CLOSED`, `COMPLETED`). 여러 값을 전달할 수 있으며, 미전달 시 전체 상태를 조회합니다. + - `departures`: 출발지 필터 (`CallvanLocation` 목록). 여러 개 선택 가능합니다. + - `departure_keyword`: 출발지 직접 입력 검색어. `departures`에 `CUSTOM`이 포함된 경우에만 유효하며, `departure_custom_name`에서 해당 검색어를 포함하는 게시글을 찾습니다. + - `arrivals`: 도착지 필터. 출발지와 동일한 방식입니다. + - `arrival_keyword`: 도착지 직접 입력 검색어. + - `title`: 게시글 제목 검색어. + - `sort`: 정렬 기준 (`DEPARTURE_ASC`(출발일 오름차순), `DEPARTURE_DESC`(출발일 내림차순), `LATEST_ASC`(게시글 등록순 오름차순), `LATEST_DESC`(게시글 등록순 내림차순)). 기본값은 `LATEST_DESC`입니다. + - `page`: 페이지 번호 (1부터 시작, 기본값 1) + - `limit`: 한 페이지당 게시글 수 (최대 50, 기본값 10) + + #### 비즈니스 로직 + 1. 출발지/도착지 필터링 시, 선택된 장소 타입들에 해당하는 게시글을 조회합니다. + 2. `CUSTOM` 타입이 선택되고 키워드가 입력된 경우, 사용자가 직접 입력한 장소명에서 해당 키워드를 포함하는 게시글도 결과에 포함됩니다. + 3. `statuses`가 전달되면 해당 상태들만 조회하고, 미전달 시 상태 조건 없이 전체를 조회합니다. + 4. `DEPARTURE_ASC`는 출발 날짜+시간 기준 오름차순, `DEPARTURE_DESC`는 출발 날짜+시간 기준 내림차순으로 정렬됩니다. + 5. `LATEST_ASC`는 게시글 등록순 오름차순, `LATEST_DESC`는 게시글 등록순 내림차순으로 정렬됩니다. + 6. 로그인된 사용자의 경우, 해당 콜벤 게시글에 합류한 상태면 `isJoined` 필드가 true로 표시됩니다. + """) + @GetMapping + ResponseEntity getCallvanPosts( + @RequestParam(required = false, defaultValue = "ALL") CallvanAuthorFilter author, + @RequestParam(required = false, name = "departures") List departures, + @RequestParam(required = false, name = "departure_keyword") String departureKeyword, + @RequestParam(required = false, name = "arrivals") List arrivals, + @RequestParam(required = false, name = "arrival_keyword") String arrivalKeyword, + @RequestParam(required = false, name = "statuses") List statuses, + @RequestParam(required = false) String title, + @RequestParam(required = false, defaultValue = "LATEST_DESC") CallvanPostSortCriteria sort, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer limit, + @UserId Integer userId + ); + + @ApiResponseCodes({ + OK, + NOT_FOUND_ARTICLE, + FORBIDDEN_PARTICIPANT + }) + @Operation(summary = "콜밴 게시글 상세 조회", description = """ + ### 콜밴 게시글 상세 조회 API + 콜밴 게시글의 상세 정보를 조회합니다. 참여자만 조회할 수 있습니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 해당 게시글의 참여자여야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 참여자가 아닌 경우(`FORBIDDEN_PARTICIPANT`) 예외가 발생합니다. + 3. 참여자 목록(`participants`)에는 닉네임이 포함됩니다. (닉네임/익명닉네임 없을 시 랜덤 생성) + """) + @GetMapping("/posts/{postId}") + ResponseEntity getCallvanPostDetail( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + CREATED, + NOT_FOUND_ARTICLE, + CALLVAN_POST_NOT_RECRUITING, + CALLVAN_POST_FULL, + CALLVAN_ALREADY_JOINED + }) + @Operation(summary = "콜밴 게시글 참여", description = """ + ### 콜밴 게시글 참여 API + 사용자가 특정 콜밴 게시글에 참여합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 모집 중인 상태(`RECRUITING`)가 아니면(`CALLVAN_POST_NOT_RECRUITING`) 예외가 발생합니다. + 3. 이미 참여한 사용자이거나 작성자인 경우(`CALLVAN_ALREADY_JOINED`) 예외가 발생합니다. + 4. 모집 인원이 꽉 찬 경우(`CALLVAN_POST_FULL`) 예외가 발생합니다. + 5. 성공 시 참여자로 등록되고, 현재 모집 인원이 1 증가합니다. + 6. 참여로 인해 모집 인원이 가득 차면, 게시글 상태가 자동으로 `CLOSED`로 변경됩니다. + """) + @PostMapping("/posts/{postId}/participants") + ResponseEntity joinCallvanPost( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + NO_CONTENT, + NOT_FOUND_ARTICLE, + FORBIDDEN_AUTHOR + }) + @Operation(summary = "콜밴 게시글 마감", description = """ + ### 콜밴 게시글 마감 API + 콜밴 게시글 작성자가 모집을 마감합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 게시글 작성자 본인이어야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 작성자가 아닌 경우(`FORBIDDEN_AUTHOR`) 예외가 발생합니다. + 3. 게시글 상태가 `CLOSED`로 변경됩니다. + """) + @PutMapping("/posts/{postId}/close") + ResponseEntity closeCallvanPost( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + NO_CONTENT, + NOT_FOUND_ARTICLE, + FORBIDDEN_AUTHOR, + CALLVAN_POST_REOPEN_FAILED_FULL, + CALLVAN_POST_REOPEN_FAILED_TIME + }) + @Operation(summary = "콜밴 게시글 재모집", description = """ + ### 콜밴 게시글 재모집 API + 콜밴 게시글 작성자가 마감된 게시글을 다시 모집 상태로 변경합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 게시글 작성자 본인이어야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 작성자가 아닌 경우(`FORBIDDEN_AUTHOR`) 예외가 발생합니다. + 3. 인원이 가득 찬 경우(`CALLVAN_POST_REOPEN_FAILED_FULL`) 예외가 발생합니다. + 4. 출발 시간이 이미 지난 경우(`CALLVAN_POST_REOPEN_FAILED_TIME`) 예외가 발생합니다. + 5. 게시글 상태가 `RECRUITING`으로 변경됩니다. + """) + @PutMapping("/posts/{postId}/reopen") + ResponseEntity reopenCallvanPost( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + NO_CONTENT, + NOT_FOUND_ARTICLE, + FORBIDDEN_AUTHOR + }) + @Operation(summary = "콜밴 게시글 완료", description = """ + ### 콜밴 게시글 완료 API + 콜밴 게시글 작성자가 모집을 완료합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 게시글 작성자 본인이어야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 작성자가 아닌 경우(`FORBIDDEN_AUTHOR`) 예외가 발생합니다. + 3. 게시글 상태가 `COMPLETED`로 변경됩니다. + 4. 마감 상태(`CLOSED`) 인 게시글만 `COMPLETED`로 변경됩니다. + """) + @PutMapping("/posts/{postId}/complete") + ResponseEntity completeCallvanPost( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + NO_CONTENT, + NOT_FOUND_ARTICLE, + FORBIDDEN_PARTICIPANT, + CALLVAN_POST_AUTHOR + }) + @Operation(summary = "콜밴 나가기", description = """ + ### 콜밴 게시글 탈퇴 API + 사용자가 참여한 콜밴 게시글에서 탈퇴합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 참여자가 아닌 경우(`FORBIDDEN_PARTICIPANT`) 예외가 발생합니다. + 3. 탈퇴 시 참여자 목록에서 삭제되고, 현재 모집 인원이 1 감소합니다. + 4. 채팅 내역에서 해당 사용자는 '나감' 상태로 표시됩니다. + 5. 콜벤 게시글 작성자는 나갈 수 없습니다. + """) + @DeleteMapping("/posts/{postId}/participants") + ResponseEntity leaveCallvanPost( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + CREATED, + NOT_FOUND_ARTICLE, + NOT_FOUND_USER, + INVALID_REQUEST_BODY, + CALLVAN_REPORT_SELF, + CALLVAN_REPORT_ONLY_PARTICIPANT, + CALLVAN_REPORT_ALREADY_PENDING + }) + @Operation(summary = "콜벤 사용자 신고", description = """ + ### 콜벤 사용자 신고 API + 같은 콜벤팟 참여자를 신고합니다. 신고는 접수 후 운영 정책에 따라 검토되며, 어드민 검토 결과에 따라 콜벤 기능 이용 제한이 적용됩니다. + + #### 요청 필드 설명 + - `reported_id`: 신고 대상 사용자 ID + - `description`: 신고 상세 내용 (선택) + - `reasons`: 신고 사유 목록 (1개 이상) + - `reason_code`: `NO_SHOW`, `NON_PAYMENT`, `PROFANITY`, `OTHER` + - `custom_text`: `OTHER`일 때만 입력 가능. `OTHER` 선택 시 `custom_text`는 필수입니다 + + #### 비즈니스 로직 + 1. 신고자와 피신고자가 동일하면 실패합니다. (`CALLVAN_REPORT_SELF`) + 2. 신고자/피신고자 모두 같은 콜벤팟 참여자여야 합니다. (`CALLVAN_REPORT_ONLY_PARTICIPANT`) + 3. 동일한 게시글에서 동일 대상에 대한 진행 중 신고(`PENDING`, `UNDER_REVIEW`)가 있으면 중복 접수할 수 없습니다. (`CALLVAN_REPORT_ALREADY_PENDING`) + """) + @PostMapping("/posts/{postId}/reports") + ResponseEntity reportCallvanUser( + @PathVariable Integer postId, + @RequestBody @Valid CallvanUserReportCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + OK, + NOT_FOUND_ARTICLE, + FORBIDDEN_PARTICIPANT + }) + @Operation(summary = "콜밴 게시글 채팅 조회", description = """ + ### 콜밴 게시글 채팅 조회 API + 콜밴 게시글의 채팅 내역을 조회합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 해당 콜벤의 참여자여야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 참여자가 아닌 경우(`FORBIDDEN_PARTICIPANT`) 예외가 발생합니다. + 3. 채팅 내역은 오래된 순으로 정렬되어 반환됩니다. + """) + @GetMapping("/posts/{postId}/chat") + ResponseEntity getCallvanChatMessages( + @PathVariable Integer postId, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + CREATED, + NOT_FOUND_ARTICLE, + FORBIDDEN_PARTICIPANT + }) + @Operation(summary = "콜밴 게시글 채팅 전송", description = """ + ### 콜밴 게시글 채팅 전송 API + 콜밴 게시글 채팅방에 메시지를 전송합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + - 해당 콜벤의 참여자여야 합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 참여자가 아닌 경우(`FORBIDDEN_PARTICIPANT`) 예외가 발생합니다. + 3. 이미지 전송 시 `is_image`를 true로 설정하고 `content`에 이미지 URL을 담아 보내야 합니다. + """) + @PostMapping("/posts/{postId}/chat") + ResponseEntity sendMessage( + @PathVariable Integer postId, + @RequestBody @Valid CallvanChatMessageRequest request, + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + OK + }) + @Operation(summary = "콜밴 알림 목록 조회", description = """ + ### 콜밴 알림 목록 조회 API + 로그인한 사용자의 알림 목록을 최신순으로 조회합니다. + + ### 알림 타입별 데이터 구조 + | 필드명 | RECRUITMENT_COMPLETE(인원 모집 완료) | NEW_MESSAGE(신규 채팅 도착) | PARTICIPANT_JOINED(신규 인원 참여) | DEPARTURE_UPCOMING(출발 30분 전) | + | :--- | :--- | :--- | :--- | :--- | + | type | RECRUITMENT_COMPLETE | NEW_MESSAGE | PARTICIPANT_JOINED | DEPARTURE_UPCOMING | + | message_preview | "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요" | 신규 채팅 메시지 내용 | null | null | + | sender_nickname | null | 발신자 닉네임 | null | null | + | joined_member_nickname | null | null | 참여자 닉네임 | null | + | post_id | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | + | departure | 출발지 | 출발지 | 출발지 | 출발지 | + | arrival | 도착지 | 도착지 | 도착지 | 도착지 | + | departure_date | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | + | departure_time | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | + | current_participants | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | + | max_participants | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | + """) + @GetMapping("/notifications") + ResponseEntity> getNotifications( + @Auth(permit = {STUDENT}) Integer userId + ); + + @ApiResponseCodes({ + NO_CONTENT + }) + @Operation(summary = "콜밴 알림 모두 읽음 처리", description = """ + ### 콜밴 알림 모두 읽음 처리 API + 로그인한 사용자의 모든 알림을 읽음 처리합니다. + """) + @PostMapping("/notifications/mark-all-read") + ResponseEntity markAllNotificationsAsRead( + @Auth(permit = {STUDENT}) Integer userId + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java new file mode 100644 index 000000000..b3d338115 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java @@ -0,0 +1,183 @@ +package in.koreatech.koin.domain.callvan.controller; + +import static in.koreatech.koin.domain.user.model.UserType.STUDENT; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostStatusFilter; +import in.koreatech.koin.domain.callvan.service.CallvanPostQueryService; +import in.koreatech.koin.domain.callvan.service.CallvanPostCreateService; +import in.koreatech.koin.domain.callvan.service.CallvanPostJoinService; +import in.koreatech.koin.domain.callvan.service.CallvanPostStatusService; +import in.koreatech.koin.domain.callvan.service.CallvanChatService; +import in.koreatech.koin.domain.callvan.service.CallvanNotificationService; +import in.koreatech.koin.domain.callvan.service.CallvanUserReportService; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.auth.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; + +@RestController +@RequestMapping("/callvan") +@RequiredArgsConstructor +public class CallvanController implements CallvanApi { + + private final CallvanPostCreateService callvanPostCreateService; + private final CallvanPostQueryService callvanPostQueryService; + private final CallvanPostJoinService callvanPostJoinService; + private final CallvanPostStatusService callvanPostStatusService; + private final CallvanChatService callvanChatService; + private final CallvanNotificationService callvanNotificationService; + private final CallvanUserReportService callvanUserReportService; + + @PostMapping + public ResponseEntity createCallvanPost( + @RequestBody CallvanPostCreateRequest request, + Integer userId + ) { + CallvanPostCreateResponse response = callvanPostCreateService.createCallvanPost(request, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity getCallvanPosts( + CallvanAuthorFilter author, + List departures, + String departureKeyword, + List arrivals, + String arrivalKeyword, + List statuses, + String title, + CallvanPostSortCriteria sort, + Integer page, + Integer limit, + @UserId Integer userId + ) { + CallvanPostSearchResponse response = callvanPostQueryService.getCallvanPosts( + author, departures, departureKeyword, arrivals, arrivalKeyword, statuses, title, sort, page, limit, + userId); + return ResponseEntity.ok().body(response); + } + + @GetMapping("/posts/{postId}") + public ResponseEntity getCallvanPostDetail( + @PathVariable Integer postId, + @UserId Integer userId + ) { + CallvanPostDetailResponse response = callvanPostQueryService.getCallvanPostDetail(postId, userId); + return ResponseEntity.ok(response); + } + + @PostMapping("/posts/{postId}/participants") + public ResponseEntity joinCallvanPost( + @PathVariable Integer postId, + @UserId Integer userId + ) { + callvanPostJoinService.join(postId, userId); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/posts/{postId}/participants") + public ResponseEntity leaveCallvanPost( + @PathVariable Integer postId, + @UserId Integer userId + ) { + callvanPostJoinService.leave(postId, userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PostMapping("/posts/{postId}/reports") + public ResponseEntity reportCallvanUser( + @PathVariable Integer postId, + @RequestBody @Valid CallvanUserReportCreateRequest request, + @Auth(permit = {STUDENT}) Integer userId + ) { + callvanUserReportService.reportUser(postId, userId, request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping("/posts/{postId}/chat") + public ResponseEntity getCallvanChatMessages( + @PathVariable Integer postId, + @UserId Integer userId + ) { + CallvanChatMessageResponse response = callvanChatService.getMessages(postId, userId); + return ResponseEntity.ok(response); + } + + @PostMapping("/posts/{postId}/chat") + public ResponseEntity sendMessage( + @PathVariable Integer postId, + @RequestBody CallvanChatMessageRequest request, + @UserId Integer userId + ) { + callvanChatService.sendMessage(postId, userId, request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/posts/{postId}/close") + public ResponseEntity closeCallvanPost( + @PathVariable Integer postId, + @UserId Integer userId + ) { + callvanPostStatusService.close(postId, userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PutMapping("/posts/{postId}/reopen") + public ResponseEntity reopenCallvanPost( + @PathVariable Integer postId, + @UserId Integer userId + ) { + callvanPostStatusService.reopen(postId, userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PutMapping("/posts/{postId}/complete") + public ResponseEntity completeCallvanPost( + @PathVariable Integer postId, + @UserId Integer userId + ) { + callvanPostStatusService.complete(postId, userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @GetMapping("/notifications") + public ResponseEntity> getNotifications( + @Auth(permit = {STUDENT}) Integer userId + ) { + List responses = callvanNotificationService.getNotifications(userId); + return ResponseEntity.ok(responses); + } + + @PostMapping("/notifications/mark-all-read") + public ResponseEntity markAllNotificationsAsRead( + @Auth(permit = {STUDENT}) Integer userId + ) { + callvanNotificationService.markAllRead(userId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageRequest.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageRequest.java new file mode 100644 index 000000000..dc88e32ca --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageRequest.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanChatMessageRequest( + @Schema(description = "메시지 내용 (텍스트 또는 이미지 URL)", example = "안녕하세요!") + @NotNull(message = "메시지 내용은 필수입니다.") + String content, + + @Schema(description = "이미지 여부", example = "false") + @JsonProperty("is_image") + @NotNull(message = "이미지 여부는 필수입니다.") + Boolean isImage +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageResponse.java new file mode 100644 index 000000000..1e4a7ae42 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanChatMessageResponse.java @@ -0,0 +1,88 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanChatMessage; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanChatMessageResponse( + @Schema(description = "채팅방 이름", example = "별관동 -> 천안역 (14:00) (4명)") + String roomName, + + @Schema(description = "메시지 목록") + List messages +) { + + public static CallvanChatMessageResponse of(CallvanPost post, List messages, Integer userId) { + String departure = post.getDepartureType().getName(); + if (StringUtils.hasText(post.getDepartureCustomName())) { + departure = post.getDepartureCustomName(); + } + + String arrival = post.getArrivalType().getName(); + if (StringUtils.hasText(post.getArrivalCustomName())) { + arrival = post.getArrivalCustomName(); + } + + String time = post.getDepartureTime().format(DateTimeFormatter.ofPattern("HH:mm")); + String roomName = String.format("%s -> %s (%s) (%d명)", + departure, arrival, time, post.getMaxParticipants()); + + List messageDtos = messages.stream() + .map(msg -> CallvanMessageDto.from(msg, userId)) + .toList(); + + return new CallvanChatMessageResponse(roomName, messageDtos); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record CallvanMessageDto( + @Schema(description = "작성자 ID", example = "1") + Integer userId, + + @Schema(description = "작성자 닉네임", example = "익명_12345") + String senderNickname, + + @Schema(description = "메시지 내용", example = "안녕하세요!") + String content, + + @Schema(description = "작성 날짜", example = "2024. 03. 24") + String date, + + @Schema(description = "작성 시간", example = "오후 2:00") + String time, + + @Schema(description = "이미지 여부", example = "false") + Boolean isImage, + + @Schema(description = "퇴장 유저 여부", example = "false") + Boolean isLeftUser, + + @Schema(description = "내 메시지 여부", example = "true") + Boolean isMine + ) { + + public static CallvanMessageDto from(CallvanChatMessage message, Integer currentUserId) { + return new CallvanMessageDto( + message.getSender().getId(), + message.getSenderNickname(), + message.getContent(), + message.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy. MM. dd")), + message.getCreatedAt().format(DateTimeFormatter.ofPattern("a h:mm").withLocale(Locale.KOREAN)), + message.getIsImage(), + message.getIsLeftUser(), + message.getSender().getId().equals(currentUserId)); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanNotificationResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanNotificationResponse.java new file mode 100644 index 000000000..203ea8963 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanNotificationResponse.java @@ -0,0 +1,90 @@ +package in.koreatech.koin.domain.callvan.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanNotification; +import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanNotificationResponse( + @Schema(description = "알림 ID", example = "1") + Integer id, + + @Schema(description = "알림 타입", example = "RECRUITMENT_COMPLETE") + CallvanNotificationType type, + + @Schema(description = "메시지 미리보기", example = "해당 콜벤팟 인원이 모두 모집되었습니다.") + String messagePreview, + + @Schema(description = "읽음 여부", example = "false") + Boolean isRead, + + @Schema(description = "생성 일시", example = "2024-03-24 14:00:00") + LocalDateTime createdAt, + + @Schema(description = "콜벤 게시글 ID", example = "1") + Integer postId, + + @Schema(description = "출발지", example = "복지관") + String departure, + + @Schema(description = "도착지", example = "천안역") + String arrival, + + @Schema(description = "출발 날짜", example = "2024-03-24") + LocalDate departureDate, + + @Schema(description = "출발 시간", example = "14:00") + LocalTime departureTime, + + @Schema(description = "현재 모집 인원", example = "2") + Integer currentParticipants, + + @Schema(description = "최대 모집 인원", example = "4") + Integer maxParticipants, + + @Schema(description = "발신자 닉네임", example = "익명_123") + String senderNickname, + + @Schema(description = "참여자 닉네임", example = "익명_456") + String joinedMemberNickname +) { + + public static CallvanNotificationResponse from(CallvanNotification notification) { + String departureName = notification.getDepartureType().getName(); + if (StringUtils.hasText(notification.getDepartureCustomName())) { + departureName = notification.getDepartureCustomName(); + } + + String arrivalName = notification.getArrivalType().getName(); + if (StringUtils.hasText(notification.getArrivalCustomName())) { + arrivalName = notification.getArrivalCustomName(); + } + + return new CallvanNotificationResponse( + notification.getId(), + notification.getNotificationType(), + notification.getMessagePreview(), + notification.getIsRead(), + notification.getCreatedAt(), + notification.getPost() != null ? notification.getPost().getId() : null, + departureName, + arrivalName, + notification.getDepartureDate(), + notification.getDepartureTime(), + notification.getCurrentParticipants(), + notification.getMaxParticipants(), + notification.getSenderNickname(), + notification.getJoinedMemberNickname() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateRequest.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateRequest.java new file mode 100644 index 000000000..02dce8b8b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateRequest.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanPostCreateRequest( + @Schema(description = "출발지 타입", example = "FRONT_GATE", requiredMode = REQUIRED) + @NotNull(message = "출발물 타입은 필수입니다.") + CallvanLocation departureType, + + @Schema(description = "출발지 직접 입력 (기타 선택 시)", example = "우리 집") + String departureCustomName, + + @Schema(description = "도착지 타입", example = "TERMINAL", requiredMode = REQUIRED) + @NotNull(message = "도착지 타입은 필수입니다.") + CallvanLocation arrivalType, + + @Schema(description = "도착지 직접 입력 (기타 선택 시)", example = "한기대 제2캠퍼스") + String arrivalCustomName, + + @Schema(description = "출발일", example = "2026-03-01", requiredMode = REQUIRED) + @NotNull(message = "출발일은 필수입니다.") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate departureDate, + + @Schema(description = "출발 시각", example = "15:30", requiredMode = REQUIRED) + @NotNull(message = "출발 시각은 필수입니다.") + @JsonFormat(pattern = "HH:mm") + LocalTime departureTime, + + @Schema(description = "모집 인원 (2~11명)", example = "4", requiredMode = REQUIRED) + @NotNull(message = "모집 인원은 필수입니다.") + @Min(value = 2, message = "모집 인원은 최소 2명입니다.") + @Max(value = 11, message = "모집 인원은 최대 11명입니다.") + Integer maxParticipants +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateResponse.java new file mode 100644 index 000000000..0eabce275 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostCreateResponse.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanPostCreateResponse( + @Schema(description = "게시글 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "작성자 닉네임", example = "코룡이", requiredMode = REQUIRED) + String author, + + @Schema(description = "출발지 타입", example = "FRONT_GATE", requiredMode = REQUIRED) + String departureType, + + @Schema(description = "출발지 직접 입력 내용", example = "복지관", requiredMode = REQUIRED) + String departureCustomName, + + @Schema(description = "도착지 타입", example = "TERMINAL", requiredMode = REQUIRED) + String arrivalType, + + @Schema(description = "도착지 직접 입력 내용", example = "야우리", requiredMode = REQUIRED) + String arrivalCustomName, + + @Schema(description = "출발일", example = "2024-03-25", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate departureDate, + + @Schema(description = "출발 시각", example = "15:30", requiredMode = REQUIRED) + @JsonFormat(pattern = "HH:mm") + LocalTime departureTime, + + @Schema(description = "최대 인원", example = "4", requiredMode = REQUIRED) + Integer maxParticipants, + + @Schema(description = "현재 인원", example = "1", requiredMode = REQUIRED) + Integer currentParticipants, + + @Schema(description = "상태", example = "RECRUITING", requiredMode = REQUIRED) + CallvanStatus status, + + @Schema(description = "생성 일시", example = "2024-03-25 15:30:00", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @Schema(description = "수정 일시", example = "2024-03-25 15:30:00", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime updatedAt +) { + + public static CallvanPostCreateResponse from(CallvanPost post) { + return new CallvanPostCreateResponse( + post.getId(), + post.getAuthor().getNickname(), + post.getDepartureType().name(), + post.getDepartureCustomName(), + post.getArrivalType().name(), + post.getArrivalCustomName(), + post.getDepartureDate(), + post.getDepartureTime(), + post.getMaxParticipants(), + post.getCurrentParticipants(), + post.getStatus(), + post.getCreatedAt(), + post.getUpdatedAt()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java new file mode 100644 index 000000000..404fcedaa --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java @@ -0,0 +1,105 @@ +package in.koreatech.koin.domain.callvan.dto; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanPostDetailResponse( + @Schema(description = "게시글 ID", example = "1") + Integer id, + + @Schema(description = "제목", example = "복지관 -> 천안역") + String title, + + @Schema(description = "출발지", example = "복지관") + String departure, + + @Schema(description = "도착지", example = "천안역") + String arrival, + + @Schema(description = "출발 날짜", example = "2024-03-24") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate departureDate, + + @Schema(description = "출발 시간", example = "14:00") + @JsonFormat(pattern = "HH:mm") + LocalTime departureTime, + + @Schema(description = "현재 모집 인원", example = "2") + Integer currentParticipants, + + @Schema(description = "최대 모집 인원", example = "4") + Integer maxParticipants, + + @Schema(description = "모집 상태", example = "RECRUITING") + String status, + + @Schema(description = "참여자 목록") + List participants +) { + + public static CallvanPostDetailResponse from(CallvanPost post, Integer userId) { + String departureName = post.getDepartureType().getName(); + if (StringUtils.hasText(post.getDepartureCustomName())) { + departureName = post.getDepartureCustomName(); + } + + String arrivalName = post.getArrivalType().getName(); + if (StringUtils.hasText(post.getArrivalCustomName())) { + arrivalName = post.getArrivalCustomName(); + } + + return new CallvanPostDetailResponse( + post.getId(), + post.getTitle(), + departureName, + arrivalName, + post.getDepartureDate(), + post.getDepartureTime(), + post.getCurrentParticipants(), + post.getMaxParticipants(), + post.getStatus().name(), + post.getParticipants().stream() + .filter(p -> !p.getIsDeleted()) + .map(it -> CallvanParticipantResponse.from(it, userId)) + .toList() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record CallvanParticipantResponse( + @Schema(description = "참여자 ID", example = "1") + Integer userId, + + @Schema(description = "참여자 닉네임", example = "익명_12345") + String nickname, + + @Schema(description = "본인 여부", example = "false") + Boolean is_me + ) { + + public static CallvanParticipantResponse from(CallvanParticipant participant, Integer userId) { + String nickname = participant.getMember().getDisplayNickname(); + Integer participantId = participant.getMember().getId(); + return new CallvanParticipantResponse( + participant.getMember().getId(), + nickname, + Objects.equals(userId, participantId) + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostSearchResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostSearchResponse.java new file mode 100644 index 000000000..f0b0a7252 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostSearchResponse.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.domain.callvan.dto; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Set; + +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CallvanPostSearchResponse( + @Schema(description = "콜밴 게시글 목록") + List posts, + + @Schema(description = "총 게시글 수", example = "57") + Long totalCount, + + @Schema(description = "현재 페이지 번호", example = "1") + Integer currentPage, + + @Schema(description = "전체 페이지 수", example = "6") + Integer totalPage +) { + public static CallvanPostSearchResponse of(List posts, Long totalCount, Integer currentPage, + Integer totalPage, Set joinedPostIds, Integer userId) { + return new CallvanPostSearchResponse( + posts.stream() + .map(post -> CallvanPostResponse.from(post, joinedPostIds.contains(post.getId()), userId)) + .toList(), + totalCount, + currentPage, + totalPage + ); + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record CallvanPostResponse( + @Schema(description = "게시글 ID", example = "1") + Integer id, + + @Schema(description = "제목", example = "복지관 -> 천안역") + String title, + + @Schema(description = "출발지", example = "복지관") + String departure, + + @Schema(description = "도착지", example = "천안역") + String arrival, + + @Schema(description = "출발 날짜", example = "2024-03-24") + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate departureDate, + + @Schema(description = "출발 시간", example = "14:00") + @JsonFormat(pattern = "HH:mm") + LocalTime departureTime, + + @Schema(description = "작성자 닉네임", example = "코인") + String authorNickname, + + @Schema(description = "현재 모집 인원", example = "2") + Integer currentParticipants, + + @Schema(description = "최대 모집 인원", example = "4") + Integer maxParticipants, + + @Schema(description = "모집 상태", example = "RECRUITING") + String status, + + @Schema(description = "참여 여부", example = "true") + Boolean isJoined, + + @Schema(description = "작성자 여부", example = "true") + Boolean isAuthor + ) { + + public static CallvanPostResponse from(CallvanPost post, boolean isJoined, Integer userId) { + String departureName = post.getDepartureType().getName(); + if (StringUtils.hasText(post.getDepartureCustomName())) { + departureName = post.getDepartureCustomName(); + } + + String arrivalName = post.getArrivalType().getName(); + if (StringUtils.hasText(post.getArrivalCustomName())) { + arrivalName = post.getArrivalCustomName(); + } + + return new CallvanPostResponse( + post.getId(), + post.getTitle(), + departureName, + arrivalName, + post.getDepartureDate(), + post.getDepartureTime(), + post.getAuthor().getNickname(), + post.getCurrentParticipants(), + post.getMaxParticipants(), + post.getStatus().name(), + isJoined, + post.getAuthor().getId().equals(userId)); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java new file mode 100644 index 000000000..767da45c2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java @@ -0,0 +1,40 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanUserReportCreateRequest( + @Schema(description = "신고 대상 사용자 ID", example = "2", requiredMode = REQUIRED) + @NotNull(message = "신고 대상 사용자는 필수입니다.") + Integer reportedUserId, + + @Schema(description = "신고 사유 목록", requiredMode = REQUIRED) + @NotEmpty(message = "신고 사유는 1개 이상 선택해야 합니다.") + @Valid + List reasons +) { + + @JsonNaming(SnakeCaseStrategy.class) + public record CallvanUserReportReasonRequest( + @Schema(description = "신고 사유 코드", example = "NON_PAYMENT", requiredMode = REQUIRED) + @NotNull(message = "신고 사유 코드는 필수입니다.") + CallvanReportReasonCode reasonCode, + + @Schema(description = "기타(OTHER) 선택 시 상세 입력", example = "욕설 및 협박성 발언") + @Size(max = 150, message = "기타 사유는 150자 이하여야 합니다.") + String customText + ) { + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java new file mode 100644 index 000000000..de4fb791b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.callvan.event; + +public record CallvanNewMessageEvent( + Integer postId, + String senderNickname, + Integer sendUserId, + String content +) { + +} + diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanParticipantJoinedEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanParticipantJoinedEvent.java new file mode 100644 index 000000000..0bdbbe780 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanParticipantJoinedEvent.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.domain.callvan.event; + +public record CallvanParticipantJoinedEvent( + Integer callvanPostId, + Integer joinUserId, + String joinUserNickname +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanRecruitmentCompletedEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanRecruitmentCompletedEvent.java new file mode 100644 index 000000000..09d59b7ac --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanRecruitmentCompletedEvent.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.callvan.event; + +public record CallvanRecruitmentCompletedEvent( + Integer postId +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatMessage.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatMessage.java new file mode 100644 index 000000000..64c287fde --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatMessage.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanMessageType; +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_chat_message") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanChatMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "callvan_chat_room_id", nullable = false) + private CallvanChatRoom chatRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + private User sender; + + @Column(name = "sender_nickname", nullable = false, length = 50) + private String senderNickname; + + @Enumerated(EnumType.STRING) + @Column(name = "message_type", nullable = false, length = 20) + private CallvanMessageType messageType = CallvanMessageType.TEXT; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "is_image", nullable = false) + private Boolean isImage = false; + + @Column(name = "is_left_user", nullable = false) + private Boolean isLeftUser = false; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + private CallvanChatMessage( + CallvanChatRoom chatRoom, + User sender, + String senderNickname, + CallvanMessageType messageType, + String content, + Boolean isImage, + Boolean isLeftUser, + Boolean isDeleted) { + this.chatRoom = chatRoom; + this.sender = sender; + this.senderNickname = senderNickname; + this.messageType = messageType != null ? messageType : CallvanMessageType.TEXT; + this.content = content; + this.isImage = isImage != null ? isImage : false; + this.isLeftUser = isLeftUser != null ? isLeftUser : false; + this.isDeleted = isDeleted != null ? isDeleted : false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatRoom.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatRoom.java new file mode 100644 index 000000000..dc6d7dd6e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanChatRoom.java @@ -0,0 +1,56 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_chat_room") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanChatRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "callvan_post_id", nullable = false, unique = true) + private CallvanPost post; + + @Column(name = "room_name", nullable = false, length = 100) + private String roomName; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + private CallvanChatRoom( + CallvanPost post, + String roomName, + Boolean isDeleted) { + this.post = post; + this.roomName = roomName; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public void determineCallvanPost(CallvanPost callvanPost) { + this.post = callvanPost; + post.updateChatRoom(this); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanNotification.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanNotification.java new file mode 100644 index 000000000..1115fe410 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanNotification.java @@ -0,0 +1,136 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; +import java.time.LocalTime; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_notification") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanNotification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipient_id", nullable = false) + private User recipient; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 30) + private CallvanNotificationType notificationType; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "callvan_post_id") + private CallvanPost post; + + @Enumerated(EnumType.STRING) + @Column(name = "departure_type", length = 20) + private CallvanLocation departureType; + + @Column(name = "departure_custom_name", length = 50) + private String departureCustomName; + + @Enumerated(EnumType.STRING) + @Column(name = "arrival_type", length = 20) + private CallvanLocation arrivalType; + + @Column(name = "arrival_custom_name", length = 50) + private String arrivalCustomName; + + @Column(name = "departure_date") + private LocalDate departureDate; + + @Column(name = "departure_time") + @Convert(disableConversion = true) + private LocalTime departureTime; + + @Column(name = "current_participants") + private Integer currentParticipants; + + @Column(name = "max_participants") + private Integer maxParticipants; + + @Column(name = "sender_nickname", length = 50) + private String senderNickname; + + @Column(name = "message_preview", length = 100) + private String messagePreview; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "callvan_chat_room_id") + private CallvanChatRoom chatRoom; + + @Column(name = "joined_member_nickname", length = 50) + private String joinedMemberNickname; + + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + private CallvanNotification( + User recipient, + CallvanNotificationType notificationType, + CallvanPost post, + CallvanLocation departureType, + String departureCustomName, + CallvanLocation arrivalType, + String arrivalCustomName, + LocalDate departureDate, + LocalTime departureTime, + Integer currentParticipants, + Integer maxParticipants, + String senderNickname, + String messagePreview, + CallvanChatRoom chatRoom, + String joinedMemberNickname, + Boolean isRead, + Boolean isDeleted) { + this.recipient = recipient; + this.notificationType = notificationType; + this.post = post; + this.departureType = departureType; + this.departureCustomName = departureCustomName; + this.arrivalType = arrivalType; + this.arrivalCustomName = arrivalCustomName; + this.departureDate = departureDate; + this.departureTime = departureTime; + this.currentParticipants = currentParticipants; + this.maxParticipants = maxParticipants; + this.senderNickname = senderNickname; + this.messagePreview = messagePreview; + this.chatRoom = chatRoom; + this.joinedMemberNickname = joinedMemberNickname; + this.isRead = isRead != null ? isRead : false; + this.isDeleted = isDeleted != null ? isDeleted : false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanParticipant.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanParticipant.java new file mode 100644 index 000000000..3fd361cd0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanParticipant.java @@ -0,0 +1,77 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanRole; +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_participant") +@NoArgsConstructor(access = PROTECTED) +public class CallvanParticipant extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private CallvanPost post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private User member; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + private CallvanRole role = CallvanRole.PARTICIPANT; + + @Column(name = "joined_at", nullable = false) + private LocalDateTime joinedAt = LocalDateTime.now(); + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + public void joinCallvanAgain() { + this.isDeleted = false; + } + + public void leaveCallvan() { + this.isDeleted = true; + } + + @Builder + private CallvanParticipant( + CallvanPost post, + User member, + CallvanRole role, + LocalDateTime joinedAt, + Boolean isDeleted + ) { + this.post = post; + this.member = member; + this.role = role != null ? role : CallvanRole.PARTICIPANT; + this.joinedAt = joinedAt != null ? joinedAt : LocalDateTime.now(); + this.isDeleted = isDeleted != null ? isDeleted : false; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java new file mode 100644 index 000000000..9ae067265 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java @@ -0,0 +1,199 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_post") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanPost extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Enumerated(EnumType.STRING) + @Column(name = "departure_type", nullable = false, length = 20) + private CallvanLocation departureType; + + @Column(name = "departure_custom_name", length = 50) + private String departureCustomName; + + @Enumerated(EnumType.STRING) + @Column(name = "arrival_type", nullable = false, length = 20) + private CallvanLocation arrivalType; + + @Column(name = "arrival_custom_name", length = 50) + private String arrivalCustomName; + + @Column(name = "departure_date", nullable = false) + private LocalDate departureDate; + + @Column(name = "departure_time", nullable = false) + @Convert(disableConversion = true) + private LocalTime departureTime; + + @Column(name = "max_participants", nullable = false) + private Integer maxParticipants; + + @Column(name = "current_participants", nullable = false) + private Integer currentParticipants = 1; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CallvanStatus status = CallvanStatus.RECRUITING; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id") + private CallvanChatRoom chatRoom; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) + private List participants = new ArrayList<>(); + + @Builder + private CallvanPost( + User author, + String title, + CallvanLocation departureType, + String departureCustomName, + CallvanLocation arrivalType, + String arrivalCustomName, + LocalDate departureDate, + LocalTime departureTime, + Integer maxParticipants, + Integer currentParticipants, + CallvanStatus status, + Boolean isDeleted + ) { + this.author = author; + this.title = title; + this.departureType = departureType; + this.departureCustomName = departureCustomName; + this.arrivalType = arrivalType; + this.arrivalCustomName = arrivalCustomName; + this.departureDate = departureDate; + this.departureTime = departureTime; + this.maxParticipants = maxParticipants; + this.currentParticipants = currentParticipants != null ? currentParticipants : 1; + this.status = status != null ? status : CallvanStatus.RECRUITING; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public void updateChatRoom(CallvanChatRoom chatRoom) { + this.chatRoom = chatRoom; + } + + public void checkJoinable() { + if (this.status != CallvanStatus.RECRUITING) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_NOT_RECRUITING); + } + if (this.currentParticipants >= this.maxParticipants) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_FULL); + } + } + + public void increaseParticipantCount() { + if (this.currentParticipants >= this.maxParticipants) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_FULL); + } + this.currentParticipants++; + if (this.currentParticipants >= this.maxParticipants) { + this.status = CallvanStatus.CLOSED; + } + } + + public void decreaseParticipantCount() { + if (this.currentParticipants > 1) { + this.currentParticipants--; + } + } + + public void closeRecruitment() { + if (this.status == CallvanStatus.RECRUITING) { + this.status = CallvanStatus.CLOSED; + } + } + + public void reopenRecruitment() { + if (this.currentParticipants >= this.maxParticipants) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_REOPEN_FAILED_FULL); + } + if (LocalDateTime.of(this.departureDate, this.departureTime).isBefore(LocalDateTime.now())) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_REOPEN_FAILED_TIME); + } + this.status = CallvanStatus.RECRUITING; + } + + public void completeRecruitment() { + if (this.status == CallvanStatus.CLOSED) { + this.status = CallvanStatus.COMPLETED; + } + } + + public void verifyReportableParticipants(Integer reporterId, Integer reportedId) { + if (reporterId.equals(reportedId)) { + throw CustomException.of(ApiResponseCode.CALLVAN_REPORT_SELF); + } + + verifyParticipantForReport(reporterId); + verifyParticipantForReport(reportedId); + } + + public void verifyAuthor(Integer authorId) { + if (!getAuthor().getId().equals(authorId)) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_AUTHOR); + } + } + + private void verifyParticipantForReport(Integer memberId) { + boolean isParticipant = this.participants.stream() + .anyMatch(participant -> participant.getMember().getId().equals(memberId)); + + if (!isParticipant) { + throw CustomException.of(ApiResponseCode.CALLVAN_REPORT_ONLY_PARTICIPANT); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java new file mode 100644 index 000000000..9a9241965 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java @@ -0,0 +1,162 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.hibernate.annotations.Where; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_report") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanReport extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "callvan_post_id") + private CallvanPost post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_id", nullable = false) + private User reported; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private CallvanReportStatus status = CallvanReportStatus.PENDING; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewer_id") + private User reviewer; + + @Column(name = "review_note", length = 500) + private String reviewNote; + + @Column(name = "reviewed_at") + private LocalDateTime reviewedAt; + + @Column(name = "confirmed_at") + private LocalDateTime confirmedAt; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @OneToMany(mappedBy = "report", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List reasons = new ArrayList<>(); + + @Builder + private CallvanReport( + CallvanPost post, + User reporter, + User reported, + String description, + CallvanReportStatus status, + User reviewer, + String reviewNote, + LocalDateTime reviewedAt, + LocalDateTime confirmedAt, + Boolean isDeleted + ) { + this.post = post; + this.reporter = reporter; + this.reported = reported; + this.description = normalizeText(description); + this.status = status != null ? status : CallvanReportStatus.PENDING; + this.reviewer = reviewer; + this.reviewNote = normalizeText(reviewNote); + this.reviewedAt = reviewedAt; + this.confirmedAt = confirmedAt; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public static CallvanReport create( + CallvanPost post, + User reporter, + User reported + ) { + return CallvanReport.builder() + .post(post) + .reporter(reporter) + .reported(reported) + .status(CallvanReportStatus.PENDING) + .build(); + } + + public void registerReasons(List reasonCommands) { + if (reasonCommands == null || reasonCommands.isEmpty()) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + Set responseCodes = new HashSet<>(); + for (CallvanReportReasonCreateCommand reasonCommand : reasonCommands) { + if (reasonCommand == null || reasonCommand.reasonCode() == null) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + if (!responseCodes.add(reasonCommand.reasonCode())) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + this.reasons.add( + CallvanReportReason.create(this, reasonCommand.reasonCode(), reasonCommand.customText()) + ); + } + } + + public void cancel() { + this.status = CallvanReportStatus.CANCELED; + } + + private static String normalizeText(String text) { + if (!StringUtils.hasText(text)) { + return null; + } + + return text.trim(); + } + + public record CallvanReportReasonCreateCommand( + CallvanReportReasonCode reasonCode, + String customText + ) { + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportReason.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportReason.java new file mode 100644 index 000000000..cd94e7f3c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportReason.java @@ -0,0 +1,105 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_report_reason") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanReportReason extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", nullable = false) + private CallvanReport report; + + @Enumerated(EnumType.STRING) + @Column(name = "reason_code", nullable = false, length = 30) + private CallvanReportReasonCode reasonCode; + + @Column(name = "custom_text", length = 200) + private String customText; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + private CallvanReportReason( + CallvanReport report, + CallvanReportReasonCode reasonCode, + String customText, + Boolean isDeleted + ) { + this.report = report; + this.reasonCode = reasonCode; + this.customText = customText; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public static CallvanReportReason create( + CallvanReport report, + CallvanReportReasonCode reasonCode, + String customText + ) { + validateReasonDetail(reasonCode, customText); + + return CallvanReportReason.builder() + .report(report) + .reasonCode(reasonCode) + .customText(normalizeCustomText(reasonCode, customText)) + .build(); + } + + private static void validateReasonDetail(CallvanReportReasonCode reasonCode, String customText) { + if (reasonCode == null) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + if (reasonCode == CallvanReportReasonCode.OTHER && !StringUtils.hasText(customText)) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + if (reasonCode != CallvanReportReasonCode.OTHER && StringUtils.hasText(customText)) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + } + + private static String normalizeCustomText(CallvanReportReasonCode reasonCode, String customText) { + if (reasonCode != CallvanReportReasonCode.OTHER) { + return null; + } + + String normalizedCustomText = customText.trim(); + if (normalizedCustomText.length() > 200) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + return normalizedCustomText; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanLocation.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanLocation.java new file mode 100644 index 000000000..027994ecb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanLocation.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +import lombok.Getter; + +@Getter +public enum CallvanLocation { + FRONT_GATE("정문"), + BACK_GATE("후문"), + TENNIS_COURT("테니스장"), + DORMITORY_MAIN("본관동"), + DORMITORY_SUB("별관동"), + TERMINAL("천안터미널"), + STATION("천안역"), + ASAN_STATION("천안아산역"), + CUSTOM("CUSTOM"); + + private final String name; + + CallvanLocation(String name) { + this.name = name; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java new file mode 100644 index 000000000..1d448d2a3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanMessageType { + TEXT, + IMAGE +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java new file mode 100644 index 000000000..77d4289b9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanNotificationType { + RECRUITMENT_COMPLETE, + NEW_MESSAGE, + PARTICIPANT_JOINED, + DEPARTURE_UPCOMING +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportReasonCode.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportReasonCode.java new file mode 100644 index 000000000..4f1979eaf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportReasonCode.java @@ -0,0 +1,8 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanReportReasonCode { + NO_SHOW, + NON_PAYMENT, + PROFANITY, + OTHER +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportStatus.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportStatus.java new file mode 100644 index 000000000..f6051730b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportStatus.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanReportStatus { + PENDING, + UNDER_REVIEW, + CONFIRMED, + REJECTED, + CANCELED +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanRole.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanRole.java new file mode 100644 index 000000000..890e50f95 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanRole.java @@ -0,0 +1,6 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanRole { + AUTHOR, + PARTICIPANT +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanStatus.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanStatus.java new file mode 100644 index 000000000..c9d0abc2b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanStatus.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanStatus { + RECRUITING, + CLOSED, + COMPLETED +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanAuthorFilter.java b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanAuthorFilter.java new file mode 100644 index 000000000..5f66b30a8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanAuthorFilter.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.callvan.model.filter; + +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CallvanAuthorFilter { + ALL { + @Override + public Integer getRequiredAuthorId(Integer userId) { + return null; + } + }, + MY { + @Override + public Integer getRequiredAuthorId(Integer userId) { + if (userId == null) { + throw CustomException.of(ApiResponseCode.UNAUTHORIZED_USER); + } + return userId; + } + }; + + public abstract Integer getRequiredAuthorId(Integer userId); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostSortCriteria.java b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostSortCriteria.java new file mode 100644 index 000000000..a3a586214 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostSortCriteria.java @@ -0,0 +1,15 @@ +package in.koreatech.koin.domain.callvan.model.filter; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CallvanPostSortCriteria { + DEPARTURE_ASC("DEPARTURE_ASC"), + DEPARTURE_DESC("DEPARTURE_DESC"), + LATEST_ASC("LATEST_ASC"), + LATEST_DESC("LATEST_DESC"); + + private final String value; +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostStatusFilter.java b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostStatusFilter.java new file mode 100644 index 000000000..285ea9a68 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/filter/CallvanPostStatusFilter.java @@ -0,0 +1,27 @@ +package in.koreatech.koin.domain.callvan.model.filter; + +import java.util.List; + +import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CallvanPostStatusFilter { + RECRUITING(CallvanStatus.RECRUITING), + CLOSED(CallvanStatus.CLOSED), + COMPLETED(CallvanStatus.COMPLETED); + + private final CallvanStatus status; + + public static List toStatuses(List filters) { + if (filters == null || filters.isEmpty()) { + return null; + } + return filters.stream() + .map(CallvanPostStatusFilter::getStatus) + .distinct() + .toList(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatMessageRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatMessageRepository.java new file mode 100644 index 000000000..910629c6d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatMessageRepository.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.callvan.model.CallvanChatMessage; + +public interface CallvanChatMessageRepository extends Repository { + + CallvanChatMessage save(CallvanChatMessage callvanChatMessage); + + List findAllByChatRoomIdOrderByCreatedAtAsc(Integer chatRoomId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE CallvanChatMessage c SET c.isLeftUser = :isLeftUser WHERE c.chatRoom.id = :chatRoomId AND c.sender.id = :senderId") + void updateIsLeftUserByChatRoomIdAndSenderId( + @Param("chatRoomId") Integer chatRoomId, + @Param("senderId") Integer senderId, + @Param("isLeftUser") boolean isLeftUser + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatRoomRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatRoomRepository.java new file mode 100644 index 000000000..750fa0386 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanChatRoomRepository.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.callvan.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.callvan.model.CallvanChatRoom; + +public interface CallvanChatRoomRepository extends Repository { + + CallvanChatRoom save(CallvanChatRoom callvanChatRoom); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java new file mode 100644 index 000000000..93e9e90b2 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java @@ -0,0 +1,19 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.callvan.model.CallvanNotification; + +public interface CallvanNotificationRepository extends JpaRepository { + + List findAllByRecipientIdOrderByCreatedAtDesc(Integer recipientId); + + @Modifying(clearAutomatically = true) + @Query("UPDATE CallvanNotification n SET n.isRead = true WHERE n.recipient.id = :recipientId") + void updateIsReadByRecipientId(@Param("recipientId") Integer recipientId); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java new file mode 100644 index 000000000..3aec9c8d7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; + +public interface CallvanParticipantRepository extends Repository { + + CallvanParticipant save(CallvanParticipant callvanParticipant); + + boolean existsByPostIdAndMemberIdAndIsDeletedFalse(Integer postId, Integer memberId); + + boolean existsByPostIdAndMemberIdAndIsDeletedTrue(Integer postId, Integer memberId); + + Optional findByPostIdAndMemberId(Integer postId, Integer memberId); + + void delete(CallvanParticipant callvanParticipant); + + List findAllByMemberIdAndPostIdIn(Integer memberId, List postIds); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostQueryRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostQueryRepository.java new file mode 100644 index 000000000..fade524ef --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostQueryRepository.java @@ -0,0 +1,164 @@ +package in.koreatech.koin.domain.callvan.repository; + +import static in.koreatech.koin.domain.callvan.model.QCallvanPost.callvanPost; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import in.koreatech.koin.common.model.Criteria; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CallvanPostQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List findCallvanPosts( + Integer authorId, + List departures, + String departureKeyword, + List arrivals, + String arrivalKeyword, + List statuses, + String title, + CallvanPostSortCriteria sort, + Criteria criteria + ) { + return queryFactory + .selectFrom(callvanPost) + .where( + authorEq(authorId), + departureFilter(departures, departureKeyword), + arrivalFilter(arrivals, arrivalKeyword), + statusIn(statuses), + titleContains(title)) + .orderBy(getOrderSpecifiers(sort)) + .offset((long)criteria.getPage() * criteria.getLimit()) + .limit(criteria.getLimit()) + .fetch(); + } + + public Long countCallvanPosts( + Integer authorId, + List departures, + String departureKeyword, + List arrivals, + String arrivalKeyword, + List statuses, + String title + ) { + return queryFactory + .select(callvanPost.count()) + .from(callvanPost) + .where( + authorEq(authorId), + departureFilter(departures, departureKeyword), + arrivalFilter(arrivals, arrivalKeyword), + statusIn(statuses), + titleContains(title)) + .fetchOne(); + } + + private BooleanExpression authorEq(Integer authorId) { + return authorId != null ? callvanPost.author.id.eq(authorId) : null; + } + + private BooleanExpression departureFilter(List departures, String departureKeyword) { + if (departures == null || departures.isEmpty()) { + return null; + } + + BooleanExpression expression = null; + + List normalLocations = departures.stream() + .filter(loc -> loc != CallvanLocation.CUSTOM) + .collect(Collectors.toList()); + + if (!normalLocations.isEmpty()) { + expression = callvanPost.departureType.in(normalLocations); + } + + if (departures.contains(CallvanLocation.CUSTOM) && departureKeyword != null) { + BooleanExpression customExpression = callvanPost.departureType.eq(CallvanLocation.CUSTOM) + .and(callvanPost.departureCustomName.containsIgnoreCase(departureKeyword)); + + expression = expression == null ? customExpression : expression.or(customExpression); + } + + return expression; + } + + private BooleanExpression arrivalFilter(List arrivals, String arrivalKeyword) { + if (arrivals == null || arrivals.isEmpty()) { + return null; + } + + BooleanExpression expression = null; + + List normalLocations = arrivals.stream() + .filter(loc -> loc != CallvanLocation.CUSTOM) + .collect(Collectors.toList()); + + if (!normalLocations.isEmpty()) { + expression = callvanPost.arrivalType.in(normalLocations); + } + + if (arrivals.contains(CallvanLocation.CUSTOM) && arrivalKeyword != null) { + BooleanExpression customExpression = callvanPost.arrivalType.eq(CallvanLocation.CUSTOM) + .and(callvanPost.arrivalCustomName.containsIgnoreCase(arrivalKeyword)); + + expression = expression == null ? customExpression : expression.or(customExpression); + } + + return expression; + } + + private BooleanExpression statusIn(List statuses) { + return statuses != null && !statuses.isEmpty() ? callvanPost.status.in(statuses) : null; + } + + private BooleanExpression titleContains(String title) { + return (title != null && !title.isBlank()) ? callvanPost.title.contains(title) : null; + } + + private OrderSpecifier[] getOrderSpecifiers(CallvanPostSortCriteria sort) { + if (sort == null) { + return new OrderSpecifier[] { + callvanPost.createdAt.desc(), + callvanPost.id.desc() + }; + } + + return switch (sort) { + case DEPARTURE_ASC -> new OrderSpecifier[] { + callvanPost.departureDate.asc(), + callvanPost.departureTime.asc(), + callvanPost.id.desc() + }; + case DEPARTURE_DESC -> new OrderSpecifier[] { + callvanPost.departureDate.desc(), + callvanPost.departureTime.desc(), + callvanPost.id.desc() + }; + case LATEST_ASC -> new OrderSpecifier[] { + callvanPost.createdAt.asc(), + callvanPost.id.asc() + }; + case LATEST_DESC -> new OrderSpecifier[] { + callvanPost.createdAt.desc(), + callvanPost.id.desc() + }; + }; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java new file mode 100644 index 000000000..a1f15ff1f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; + +public interface CallvanPostRepository extends Repository { + + CallvanPost save(CallvanPost callvanPost); + + Optional findById(Integer id); + + default CallvanPost getById(Integer postId) { + return findById(postId).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_ARTICLE)); + } + + List findAllByDepartureDateAndDepartureTimeAndIsDeletedFalse(LocalDate departureDate, + LocalTime departureTime); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java new file mode 100644 index 000000000..f3f3f345b --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; + +public interface CallvanReportRepository extends Repository { + + CallvanReport save(CallvanReport callvanReport); + + boolean existsByPostIdAndReporterIdAndReportedIdAndStatusInAndIsDeletedFalse( + Integer postId, + Integer reporterId, + Integer reportedId, + List statuses + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java new file mode 100644 index 000000000..1c9b1fb14 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java @@ -0,0 +1,73 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.util.List; + +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.event.CallvanNewMessageEvent; + +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; +import in.koreatech.koin.domain.callvan.model.CallvanChatMessage; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanMessageType; +import in.koreatech.koin.domain.callvan.repository.CallvanChatMessageRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanParticipantRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanChatService { + + private final CallvanPostRepository callvanPostRepository; + private final CallvanParticipantRepository callvanParticipantRepository; + private final CallvanChatMessageRepository callvanChatMessageRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + public CallvanChatMessageResponse getMessages(Integer postId, Integer userId) { + if (!callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId)) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT); + } + + CallvanPost callvanPost = callvanPostRepository.getById(postId); + + List messages = callvanChatMessageRepository.findAllByChatRoomIdOrderByCreatedAtAsc( + callvanPost.getChatRoom().getId()); + + return CallvanChatMessageResponse.of(callvanPost, messages, userId); + } + + @Transactional + public void sendMessage(Integer postId, Integer userId, CallvanChatMessageRequest request) { + if (!callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId)) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT); + } + + CallvanPost callvanPost = callvanPostRepository.getById(postId); + User sender = userRepository.getById(userId); + + CallvanChatMessage message = CallvanChatMessage.builder() + .chatRoom(callvanPost.getChatRoom()) + .sender(sender) + .senderNickname(sender.getDisplayNickname()) + .content(request.content()) + .messageType(request.isImage() ? CallvanMessageType.IMAGE : CallvanMessageType.TEXT) + .isImage(request.isImage()) + .build(); + + callvanChatMessageRepository.save(message); + + eventPublisher.publishEvent( + new CallvanNewMessageEvent(callvanPost.getId(), message.getSenderNickname(), sender.getId(), message.getContent())); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java new file mode 100644 index 000000000..02f50a47e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.domain.callvan.service; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.domain.callvan.event.CallvanNewMessageEvent; +import in.koreatech.koin.domain.callvan.event.CallvanParticipantJoinedEvent; +import in.koreatech.koin.domain.callvan.event.CallvanRecruitmentCompletedEvent; +import in.koreatech.koin.domain.callvan.model.CallvanNotification; +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; +import in.koreatech.koin.domain.user.model.User; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CallvanNotificationEventListener { + + private final CallvanNotificationService callvanNotificationService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRecruitmentCompleted(CallvanRecruitmentCompletedEvent event) { + callvanNotificationService.notifyRecruitmentCompleted(event.postId()); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onNewMessage(CallvanNewMessageEvent event) { + callvanNotificationService.notifyNewMessageReceived(event.postId(), event.sendUserId(), event.senderNickname(), + event.content()); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onParticipantJoined(CallvanParticipantJoinedEvent event) { + callvanNotificationService.notifyParticipantJoined(event.callvanPostId(), event.joinUserId(), + event.joinUserNickname()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java new file mode 100644 index 000000000..0b5994db4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java @@ -0,0 +1,127 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Set; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.domain.callvan.model.CallvanNotification; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CallvanNotificationScheduler { + + private final CallvanPostRepository callvanPostRepository; + private final CallvanNotificationRepository callvanNotificationRepository; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final ZoneId systemZone = ZoneId.of("Asia/Seoul"); + private static final String NOTIFICATION_QUEUE_KEY = "callvan:notification:queue"; + + public void scheduleNotification(CallvanPost post) { + LocalDateTime departureTime = LocalDateTime.of( + post.getDepartureDate(), + post.getDepartureTime() + ); + LocalDateTime notificationTime = departureTime.minusMinutes(30); + LocalDateTime now = LocalDateTime.now(); + + if (notificationTime.isBefore(now) || notificationTime.isEqual(now)) { + log.info("콜벤팟 알림 시간이 이미 지나서 스케줄링하지 않음 - postId: {}, notificationTime: {}", + post.getId(), notificationTime + ); + return; + } + + long score = notificationTime.atZone(systemZone).toEpochSecond(); + + CallvanNotificationTask task = CallvanNotificationTask.builder() + .postId(post.getId()) + .type(CallvanNotificationType.DEPARTURE_UPCOMING) + .build(); + + try { + String taskJson = objectMapper.writeValueAsString(task); + redisTemplate.opsForZSet().add(NOTIFICATION_QUEUE_KEY, taskJson, score); + } catch (JsonProcessingException e) { + log.info("콜벤팟 알림 작업 생성 실패 : {}", post.getId(), e); + } + } + + @Scheduled(cron = "0 * * * * *") + @Transactional + public void processScheduledNotifications() { + long now = ZonedDateTime.now(systemZone).toEpochSecond(); + + Set tasks = redisTemplate.opsForZSet() + .rangeByScore(NOTIFICATION_QUEUE_KEY, 0, now); + + if (tasks == null || tasks.isEmpty()) { + return; + } + + for (String taskJson : tasks) { + try { + CallvanNotificationTask task = objectMapper.readValue(taskJson, CallvanNotificationTask.class); + processNotification(task); + + redisTemplate.opsForZSet().remove(NOTIFICATION_QUEUE_KEY, taskJson); + } catch (Exception e) { + log.info("콜벤팟 알림 작업 처리 실패 : {}", e.getMessage()); + } + } + } + + private void processNotification(CallvanNotificationTask task) { + CallvanPost post = callvanPostRepository.findById(task.getPostId()) + .orElse(null); + + if (post == null || post.getIsDeleted()) { + return; + } + + List notifications = post.getParticipants().stream() + .filter(p -> !p.getIsDeleted()) + .map(participant -> CallvanNotification.builder() + .recipient(participant.getMember()) + .notificationType(CallvanNotificationType.DEPARTURE_UPCOMING) + .post(post) + .departureType(post.getDepartureType()) + .departureCustomName(post.getDepartureCustomName()) + .arrivalType(post.getArrivalType()) + .arrivalCustomName(post.getArrivalCustomName()) + .departureDate(post.getDepartureDate()) + .departureTime(post.getDepartureTime()) + .currentParticipants(post.getCurrentParticipants()) + .maxParticipants(post.getMaxParticipants()) + .build()) + .toList(); + + callvanNotificationRepository.saveAll(notifications); + } + + @Builder + @Getter + private static class CallvanNotificationTask { + private Integer postId; + private CallvanNotificationType type; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java new file mode 100644 index 000000000..13d6dca48 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java @@ -0,0 +1,107 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; +import in.koreatech.koin.domain.callvan.model.CallvanNotification; +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.user.model.User; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanNotificationService { + + private final CallvanPostRepository callvanPostRepository; + private final CallvanNotificationRepository callvanNotificationRepository; + + public List getNotifications(Integer userId) { + return callvanNotificationRepository.findAllByRecipientIdOrderByCreatedAtDesc(userId).stream() + .map(CallvanNotificationResponse::from) + .toList(); + } + + @Transactional + public void markAllRead(Integer userId) { + callvanNotificationRepository.updateIsReadByRecipientId(userId); + } + + @Transactional + public void notifyRecruitmentCompleted(Integer postId) { + CallvanPost post = callvanPostRepository.getById(postId); + + List notifications = post.getParticipants().stream() + .filter(p -> !p.getIsDeleted()) + .map(p -> buildNotification(p.getMember(), CallvanNotificationType.RECRUITMENT_COMPLETE, post, + null, "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요", null)) + .toList(); + + if (!notifications.isEmpty()) { + callvanNotificationRepository.saveAll(notifications); + } + } + + @Transactional + public void notifyParticipantJoined(Integer postId, Integer joinUserId, String joinUserNickname) { + CallvanPost post = callvanPostRepository.getById(postId); + + List notifications = post.getParticipants().stream() + .filter(p -> !p.getIsDeleted()) + .filter(p -> !p.getMember().getId().equals(joinUserId)) + .map(p -> buildNotification(p.getMember(), CallvanNotificationType.PARTICIPANT_JOINED, post, + null, null, joinUserNickname)) + .toList(); + + if (!notifications.isEmpty()) { + callvanNotificationRepository.saveAll(notifications); + } + } + + @Transactional + public void notifyNewMessageReceived(Integer postId, Integer senderId, String senderNickname, String messageContent) { + CallvanPost post = callvanPostRepository.getById(postId); + + List notifications = post.getParticipants().stream() + .filter(p -> !p.getIsDeleted()) + .filter(p -> !p.getMember().getId().equals(senderId)) + .map(p -> buildNotification(p.getMember(), CallvanNotificationType.NEW_MESSAGE, post, + senderNickname, messageContent, null)) + .toList(); + + if (!notifications.isEmpty()) { + callvanNotificationRepository.saveAll(notifications); + } + } + + + private CallvanNotification buildNotification(User recipient, CallvanNotificationType type, CallvanPost post, + String senderNickname, String messagePreview, String joinedMemberNickname + ) { + return CallvanNotification.builder() + .recipient(recipient) + .notificationType(type) + .post(post) + .departureType(post.getDepartureType()) + .departureCustomName(post.getDepartureCustomName()) + .arrivalType(post.getArrivalType()) + .arrivalCustomName(post.getArrivalCustomName()) + .departureDate(post.getDepartureDate()) + .departureTime(post.getDepartureTime()) + .currentParticipants(post.getCurrentParticipants()) + .maxParticipants(post.getMaxParticipants()) + .senderNickname(senderNickname) + .messagePreview(messagePreview) + .chatRoom(post.getChatRoom()) + .joinedMemberNickname(joinedMemberNickname) + .build(); + } + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java new file mode 100644 index 000000000..01eee83ba --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java @@ -0,0 +1,95 @@ +package in.koreatech.koin.domain.callvan.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; +import in.koreatech.koin.domain.callvan.model.CallvanChatRoom; +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanRole; +import in.koreatech.koin.domain.callvan.repository.CallvanChatRoomRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanParticipantRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanPostCreateService { + + private final CallvanPostRepository callvanPostRepository; + private final CallvanParticipantRepository callvanParticipantRepository; + private final CallvanChatRoomRepository callvanChatRoomRepository; + private final UserRepository userRepository; + private final CallvanNotificationScheduler callvanNotificationScheduler; + + @Transactional + public CallvanPostCreateResponse createCallvanPost(CallvanPostCreateRequest request, Integer userId) { + User user = userRepository.getById(userId); + + validateLocation(request.departureType(), request.departureCustomName()); + validateLocation(request.arrivalType(), request.arrivalCustomName()); + + CallvanPost callvanPost = CallvanPost.builder() + .author(user) + .title(generateTitle(request)) + .departureType(request.departureType()) + .departureCustomName(getLocationName(request.departureType(), request.departureCustomName())) + .arrivalType(request.arrivalType()) + .arrivalCustomName(getLocationName(request.arrivalType(), request.arrivalCustomName())) + .departureDate(request.departureDate()) + .departureTime(request.departureTime()) + .maxParticipants(request.maxParticipants()) + .build(); + callvanPostRepository.save(callvanPost); + + CallvanParticipant callvanParticipant = CallvanParticipant.builder() + .post(callvanPost) + .member(user) + .role(CallvanRole.AUTHOR) + .build(); + callvanParticipantRepository.save(callvanParticipant); + + CallvanChatRoom callvanChatRoom = CallvanChatRoom.builder() + .roomName(generateCallvanChatRoomRoomName(request)) + .build(); + callvanChatRoom.determineCallvanPost(callvanPost); + callvanChatRoomRepository.save(callvanChatRoom); + + callvanNotificationScheduler.scheduleNotification(callvanPost); + return CallvanPostCreateResponse.from(callvanPost); + } + + private void validateLocation(CallvanLocation type, String customName) { + if (type != CallvanLocation.CUSTOM && customName != null && !customName.isBlank()) { + throw CustomException.of(ApiResponseCode.INVALID_CUSTOM_LOCATION_NAME); + } + if (type == CallvanLocation.CUSTOM && (customName == null || customName.isBlank())) { + throw CustomException.of(ApiResponseCode.INVALID_CUSTOM_LOCATION_NAME); + } + } + + private String getLocationName(CallvanLocation type, String customName) { + if (type == CallvanLocation.CUSTOM) { + return customName; + } + return type.getName(); + } + + private String generateTitle(CallvanPostCreateRequest request) { + String departure = getLocationName(request.departureType(), request.departureCustomName()); + String arrival = getLocationName(request.arrivalType(), request.arrivalCustomName()); + return String.format("%s -> %s", departure, arrival); + } + + private String generateCallvanChatRoomRoomName(CallvanPostCreateRequest request) { + return generateTitle(request) + " " + request.departureTime(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java new file mode 100644 index 000000000..52454b7aa --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java @@ -0,0 +1,96 @@ +package in.koreatech.koin.domain.callvan.service; + +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.event.CallvanParticipantJoinedEvent; +import in.koreatech.koin.domain.callvan.event.CallvanRecruitmentCompletedEvent; + +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.repository.CallvanChatMessageRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanParticipantRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.concurrent.ConcurrencyGuard; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CallvanPostJoinService { + + private final CallvanPostRepository callvanPostRepository; + private final CallvanParticipantRepository callvanParticipantRepository; + private final CallvanChatMessageRepository callvanChatMessageRepository; + private final UserRepository userRepository; + private final EntityManager entityManager; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + @ConcurrencyGuard(lockName = "callvanJoin") + public void join(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + User user = userRepository.getById(userId); + + if (callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId)) { + throw CustomException.of(ApiResponseCode.CALLVAN_ALREADY_JOINED); + } + + callvanPost.checkJoinable(); + callvanPost.increaseParticipantCount(); + + if (callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedTrue(postId, userId)) { + CallvanParticipant callvanParticipant = callvanParticipantRepository.findByPostIdAndMemberId(postId, userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT)); + callvanParticipant.joinCallvanAgain(); + } else { + CallvanParticipant participant = CallvanParticipant.builder() + .post(callvanPost) + .member(user) + .build(); + + callvanParticipantRepository.save(participant); + } + entityManager.flush(); + updateUserCallvanChatMessage(callvanPost.getChatRoom().getId(), userId, false); + + eventPublisher.publishEvent(new CallvanParticipantJoinedEvent(callvanPost.getId(), user.getId(), user.getDisplayNickname())); + + if (callvanPost.getCurrentParticipants() >= callvanPost.getMaxParticipants()) { + eventPublisher.publishEvent(new CallvanRecruitmentCompletedEvent(callvanPost.getId())); + } + } + + @Transactional + public void leave(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + + if (callvanPost.getAuthor().getId().equals(userId)) { + throw CustomException.of(ApiResponseCode.CALLVAN_POST_AUTHOR); + } + + CallvanParticipant participant = callvanParticipantRepository.findByPostIdAndMemberId(postId, userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT)); + + if (participant.getIsDeleted()) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT); + } + + callvanPost.decreaseParticipantCount(); + participant.leaveCallvan(); + entityManager.flush(); + updateUserCallvanChatMessage(callvanPost.getChatRoom().getId(), userId, true); + } + + public void updateUserCallvanChatMessage(Integer postId, Integer userId, Boolean isLeft) { + callvanChatMessageRepository.updateIsLeftUserByChatRoomIdAndSenderId( + postId, userId, isLeft + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java new file mode 100644 index 000000000..bccddcd5f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java @@ -0,0 +1,96 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.common.model.Criteria; +import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; +import in.koreatech.koin.domain.callvan.model.CallvanParticipant; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; +import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; +import in.koreatech.koin.domain.callvan.model.filter.CallvanPostStatusFilter; +import in.koreatech.koin.domain.callvan.repository.CallvanParticipantRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostQueryRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanPostQueryService { + + private final CallvanPostQueryRepository callvanPostQueryRepository; + private final CallvanParticipantRepository callvanParticipantRepository; + private final CallvanPostRepository callvanPostRepository; + + public CallvanPostSearchResponse getCallvanPosts( + CallvanAuthorFilter authorFilter, + List departures, + String departureKeyword, + List arrivals, + String arrivalKeyword, + List statusFilters, + String title, + CallvanPostSortCriteria sort, + Integer page, + Integer limit, + Integer userId + ) { + Integer authorId = authorFilter.getRequiredAuthorId(userId); + List statuses = CallvanPostStatusFilter.toStatuses(statusFilters); + + Long totalCount = callvanPostQueryRepository.countCallvanPosts( + authorId, departures, departureKeyword, arrivals, arrivalKeyword, statuses, title); + + Criteria criteria = Criteria.of(page, limit, totalCount.intValue()); + + List posts = callvanPostQueryRepository.findCallvanPosts( + authorId, departures, departureKeyword, arrivals, arrivalKeyword, statuses, title, sort, + criteria); + + int totalPage = (int)Math.ceil((double)totalCount / criteria.getLimit()); + if (totalPage == 0) + totalPage = 1; + + Set joinedPostIds = Collections.emptySet(); + if (userId != null && !posts.isEmpty()) { + List postIds = posts.stream() + .map(CallvanPost::getId) + .toList(); + List participants = callvanParticipantRepository.findAllByMemberIdAndPostIdIn(userId, + postIds); + joinedPostIds = participants.stream() + .map(participant -> participant.getPost().getId()) + .collect(Collectors.toSet()); + } + + return CallvanPostSearchResponse.of( + posts, + totalCount, + criteria.getPage() + 1, + totalPage, + joinedPostIds, + userId); + } + + public CallvanPostDetailResponse getCallvanPostDetail(Integer postId, Integer userId) { + if (!callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId)) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT); + } + CallvanPost callvanPost = callvanPostRepository.getById(postId); + + return CallvanPostDetailResponse.from(callvanPost, userId); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostStatusService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostStatusService.java new file mode 100644 index 000000000..63f833299 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostStatusService.java @@ -0,0 +1,47 @@ +package in.koreatech.koin.domain.callvan.service; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.event.CallvanRecruitmentCompletedEvent; + +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanPostStatusService { + + private final CallvanPostRepository callvanPostRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void close(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + callvanPost.verifyAuthor(userId); + + callvanPost.closeRecruitment(); + eventPublisher.publishEvent(new CallvanRecruitmentCompletedEvent(callvanPost.getId())); + } + + @Transactional + public void reopen(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + callvanPost.verifyAuthor(userId); + + callvanPost.reopenRecruitment(); + } + + @Transactional + public void complete(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + callvanPost.verifyAuthor(userId); + + callvanPost.completeRecruitment(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java new file mode 100644 index 000000000..69c16cab8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.domain.callvan.service; + +import static in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus.PENDING; +import static in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus.UNDER_REVIEW; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; +import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanUserReportService { + + private static final List ACTIVE_REPORT_STATUSES = List.of(PENDING, UNDER_REVIEW); + + private final CallvanPostRepository callvanPostRepository; + private final UserRepository userRepository; + private final CallvanReportRepository callvanReportRepository; + + @Transactional + public void reportUser( + Integer postId, + Integer reporterId, + CallvanUserReportCreateRequest request + ) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + User reporter = userRepository.getById(reporterId); + User reported = userRepository.getById(request.reportedUserId()); + + callvanPost.verifyReportableParticipants(reporter.getId(), reported.getId()); + + if (callvanReportRepository.existsByPostIdAndReporterIdAndReportedIdAndStatusInAndIsDeletedFalse( + callvanPost.getId(), + reporter.getId(), + reported.getId(), + ACTIVE_REPORT_STATUSES + )) { + throw CustomException.of(ApiResponseCode.CALLVAN_REPORT_ALREADY_PENDING); + } + + CallvanReport callvanReport = CallvanReport.create( + callvanPost, + reporter, + reported + ); + + callvanReport.registerReasons( + request.reasons().stream() + .map(reason -> new CallvanReport.CallvanReportReasonCreateCommand( + reason.reasonCode(), + reason.customText() + )) + .toList() + ); + + callvanReportRepository.save(callvanReport); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/model/User.java b/src/main/java/in/koreatech/koin/domain/user/model/User.java index 4775d2a56..22c0b8c5c 100644 --- a/src/main/java/in/koreatech/koin/domain/user/model/User.java +++ b/src/main/java/in/koreatech/koin/domain/user/model/User.java @@ -244,4 +244,14 @@ public Integer authorizeAndGetId(UserType[] permittedUserTypes) { ensureAuthed(); return id; } + + public String getDisplayNickname() { + if (StringUtils.hasText(this.nickname)) { + return this.nickname; + } + if (StringUtils.hasText(this.anonymousNickname)) { + return this.anonymousNickname; + } + return "익명 사용자"; + } } diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index 931874b63..055a9959b 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -28,6 +28,7 @@ public enum ApiResponseCode { INVALID_DATE_TIME(HttpStatus.BAD_REQUEST, "잘못된 날짜 형식입니다."), INVALID_GENDER_INDEX(HttpStatus.BAD_REQUEST, "올바르지 않은 성별 인덱스입니다."), INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "올바르지 않은 인증 토큰입니다."), + INVALID_CUSTOM_LOCATION_NAME(HttpStatus.BAD_REQUEST, "기타 장소명이 올바르지 않습니다."), INVALID_DELIVERY_AREA(HttpStatus.BAD_REQUEST, "배달이 불가능한 지역이에요."), INVALID_DELIVERY_BUILDING(HttpStatus.BAD_REQUEST, "교외 주문 주소에 교내 주문 주소를 입력할 수 없습니다."), NOT_MATCHED_EMAIL(HttpStatus.BAD_REQUEST, "이메일이 일치하지 않습니다."), @@ -84,6 +85,12 @@ public enum ApiResponseCode { INVALID_SEMESTER_FORMAT(HttpStatus.BAD_REQUEST, "올바르지 않은 학기 형식입니다."), INVALID_DETAIL_SUBSCRIBE_TYPE(HttpStatus.BAD_REQUEST, "세부 구독 타입이 구독 타입에 속하지 않습니다."), CANNOT_UPDATE_FOUND_ITEM(HttpStatus.BAD_REQUEST, "이미 찾은 물건의 정보를 수정할 수 없습니다"), + CALLVAN_POST_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "모집 중인 게시글이 아닙니다."), + CALLVAN_POST_FULL(HttpStatus.BAD_REQUEST, "참여 인원이 가득 찼습니다."), + CALLVAN_POST_REOPEN_FAILED_FULL(HttpStatus.BAD_REQUEST, "인원이 가득 차서 모집을 다시 열 수 없습니다."), + CALLVAN_POST_REOPEN_FAILED_TIME(HttpStatus.BAD_REQUEST, "출발 시간이 지나서 모집을 다시 열 수 없습니다."), + CALLVAN_POST_AUTHOR(HttpStatus.BAD_REQUEST, "콜벤 게시글 작성자는 나갈 수 없습니다"), + CALLVAN_REPORT_SELF(HttpStatus.BAD_REQUEST, "자기 자신은 신고할 수 없습니다."), /** * 401 Unauthorized (인증 필요) @@ -105,6 +112,8 @@ public enum ApiResponseCode { FORBIDDEN_ORDER(HttpStatus.FORBIDDEN, "주문 정보 접근 권한이 없습니다."), FORBIDDEN_SHOP_OWNER(HttpStatus.FORBIDDEN, "상점의 사장님이 아닙니다."), FORBIDDEN_AUTHOR(HttpStatus.FORBIDDEN, "게시글 접근 권한이 없습니다."), + FORBIDDEN_PARTICIPANT(HttpStatus.FORBIDDEN, "콜벤 게시글 참여자가 아닙니다."), + CALLVAN_REPORT_ONLY_PARTICIPANT(HttpStatus.FORBIDDEN, "같은 콜벤팟 참여자만 신고할 수 있습니다."), /** * 404 Not Found (리소스를 찾을 수 없음) @@ -149,6 +158,8 @@ public enum ApiResponseCode { DUPLICATE_SEMESTER(HttpStatus.CONFLICT, "이미 존재하는 학기입니다."), OVERLAPPING_SEMESTER_DATE_RANGE(HttpStatus.CONFLICT, "학기 기간이 기존 학기와 겹칩니다."), DUPLICATE_FOUND_STATUS(HttpStatus.CONFLICT, "이미 찾음 처리된 분실물 게시글입니다."), + CALLVAN_ALREADY_JOINED(HttpStatus.CONFLICT, "이미 참여한 게시글입니다."), + CALLVAN_REPORT_ALREADY_PENDING(HttpStatus.CONFLICT, "이미 접수된 신고가 있어 추가 신고할 수 없습니다."), /** * 429 Too Many Requests (요청량 초과) diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index d2b126de4..cb6956325 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -57,6 +57,7 @@ public GroupedOpenApi campusApi() { "in.koreatech.koin.domain.dining", "in.koreatech.koin.domain.banner", "in.koreatech.koin.domain.club", + "in.koreatech.koin.domain.callvan" }); } diff --git a/src/main/resources/db/migration/V229__add_call_van_table.sql b/src/main/resources/db/migration/V229__add_call_van_table.sql new file mode 100644 index 000000000..08e8a7ce6 --- /dev/null +++ b/src/main/resources/db/migration/V229__add_call_van_table.sql @@ -0,0 +1,95 @@ +CREATE TABLE IF NOT EXISTS `koin`.`callvan_post` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `author_id` INT NOT NULL COMMENT '작성자 user_id', + `title` VARCHAR(100) NOT NULL , + `departure_type` VARCHAR(20) NOT NULL COMMENT '정문, 후문, 테니스장, 본관동, 별관동, 천안터미널, 천안역, 천안아산역, CUSTOM', + `departure_custom_name` VARCHAR(50) NULL COMMENT 'departure_type이 CUSTOM일 때 사용', + `arrival_type` VARCHAR(20) NOT NULL COMMENT '정문, 후문, 테니스장, 본관동, 별관동, 천안터미널, 천안역, 천안아산역, CUSTOM', + `arrival_custom_name` VARCHAR(50) NULL COMMENT 'arrival_type이 CUSTOM일 때 사용', + `departure_date` DATE NOT NULL, + `departure_time` TIME NOT NULL, + `max_participants` INT NOT NULL COMMENT '2~11명', + `current_participants` INT NOT NULL DEFAULT 1, + `status` VARCHAR(20) NOT NULL DEFAULT 'RECRUITING' COMMENT 'RECRUITING, CLOSED, COMPLETED', + `chat_room_id` INT NULL, + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시' +); +CREATE INDEX `idx_callvan_post_status` ON `koin`.`callvan_post` (`status`); +CREATE INDEX `idx_callvan_post_departure_date_time` ON `koin`.`callvan_post` (`departure_date`, `departure_time`); +CREATE INDEX `idx_callvan_post_author_id` ON `koin`.`callvan_post` (`author_id`); +CREATE INDEX `idx_callvan_post_departure_type` ON `koin`.`callvan_post` (`departure_type`); +CREATE INDEX `idx_callvan_post_arrival_type` ON `koin`.`callvan_post` (`arrival_type`); +CREATE INDEX `idx_callvan_post_filter_composite` ON `koin`.`callvan_post` (`status`, `departure_date`, `departure_time`); +CREATE INDEX `idx_callvan_post_location_composite` ON `koin`.`callvan_post` (`departure_type`, `arrival_type`, `status`); + +CREATE TABLE IF NOT EXISTS `koin`.`callvan_participant` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `post_id` INT NOT NULL, + `member_id` INT NOT NULL, + `role` VARCHAR(20) NOT NULL DEFAULT 'PARTICIPANT' COMMENT 'AUTHOR, PARTICIPANT', + `joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시', + UNIQUE KEY `uk_participant_post_member` (`post_id`, `member_id`) +); + +CREATE INDEX `idx_participant_member_id` ON `koin`.`callvan_participant` (`member_id`); +CREATE INDEX `idx_participant_post_id` ON `koin`.`callvan_participant` (`post_id`); + +CREATE TABLE IF NOT EXISTS `koin`.`callvan_chat_room` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `callvan_post_id` INT NOT NULL UNIQUE, + `room_name` VARCHAR(100) NOT NULL COMMENT '출발지 -> 도착지 시간 인원수 형식', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시' +); + +CREATE TABLE IF NOT EXISTS `koin`.`callvan_chat_message` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `callvan_chat_room_id` INT NOT NULL, + `sender_id` INT NOT NULL, + `sender_nickname` VARCHAR(50) NOT NULL COMMENT '비정규화', + `message_type` VARCHAR(20) NOT NULL DEFAULT 'TEXT' COMMENT 'TEXT, IMAGE', + `content` TEXT NULL, + `is_image` TINYINT(1) NOT NULL DEFAULT '0', + `is_left_user` TINYINT(1) NOT NULL DEFAULT '0', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시' +); + +CREATE INDEX `idx_chat_message_room_created` ON `koin`.`callvan_chat_message` (`callvan_chat_room_id`, `created_at`); + +CREATE TABLE IF NOT EXISTS `koin`.`callvan_notification` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `recipient_id` INT NOT NULL, + `notification_type` VARCHAR(30) NOT NULL COMMENT 'RECRUITMENT_COMPLETE, NEW_MESSAGE, PARTICIPANT_JOINED, DEPARTURE_IMMINENT', + + `callvan_post_id` INT NULL, + `departure_type` VARCHAR(20) NULL, + `departure_custom_name` VARCHAR(50) NULL, + `arrival_type` VARCHAR(20) NULL, + `arrival_custom_name` VARCHAR(50) NULL, + `departure_date` DATE NULL, + `departure_time` TIME NULL, + `current_participants` TINYINT NULL, + `max_participants` TINYINT NULL, + + `sender_nickname` VARCHAR(50) NULL, + `message_preview` VARCHAR(100) NULL, + `callvan_chat_room_id` INT NULL, + + `joined_member_nickname` VARCHAR(50) NULL, + + `is_read` TINYINT(1) NOT NULL DEFAULT 0, + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시' +); + +CREATE INDEX `idx_notification_recipient_read` ON `koin`.`callvan_notification` (`recipient_id`, `is_read`); diff --git a/src/main/resources/db/migration/V230__add_call_van_report_table.sql b/src/main/resources/db/migration/V230__add_call_van_report_table.sql new file mode 100644 index 000000000..12fe9fde9 --- /dev/null +++ b/src/main/resources/db/migration/V230__add_call_van_report_table.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS `koin`.`callvan_report` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `callvan_post_id` INT NULL COMMENT '신고 접수된 콜벤팟 게시글 id', + `reporter_id` INT NOT NULL COMMENT '신고자 user_id', + `reported_id` INT NOT NULL COMMENT '피신고자 user_id', + `description` TEXT NULL COMMENT '신고 상세 내용(상황 설명 등)', + `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING, UNDER_REVIEW, CONFIRMED, REJECTED, CANCELED', + `reviewer_id` INT NULL COMMENT '운영 검토자 user_id (Admin)', + `review_note` VARCHAR(500) NULL COMMENT '운영 메모/판단 근거', + `reviewed_at` TIMESTAMP NULL, + `confirmed_at` TIMESTAMP NULL COMMENT 'CONFIRMED 시각(누적/제재 기준 시각)', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX `idx_callvan_report_status_created` (`status`, `created_at`), + INDEX `idx_callvan_report_reported_status` (`reported_id`, `status`, `confirmed_at`), + INDEX `idx_callvan_report_reporter_created` (`reporter_id`, `created_at`), + INDEX `idx_callvan_report_post` (`callvan_post_id`) +); + +CREATE TABLE IF NOT EXISTS `koin`.`callvan_report_reason` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `report_id` INT NOT NULL COMMENT 'callvan_report.id', + `reason_code` VARCHAR(30) NOT NULL COMMENT 'NO_SHOW, NON_PAYMENT, PROFANITY, OTHER', + `custom_text` VARCHAR(200) NULL COMMENT '기타 사유 직접 입력', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX `idx_report_reason_report` (`report_id`), + INDEX `idx_report_reason_code` (`reason_code`) +);