diff --git a/.gitignore b/.gitignore index 861ec8d5..8376cd11 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ src/main/generated/ /.claude/ CLAUDE.md /.ai/ +.mcp.json diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/ErrorResponse.java b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/ErrorResponse.java index fc35571c..5965d9d5 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/ErrorResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/common/dto/ErrorResponse.java @@ -47,4 +47,13 @@ public static ErrorResponse of(ErrorCode errorCode, T data) { ); } + public static ErrorResponse of(ErrorCode errorCode, String message, T data) { + return new ErrorResponse<>( + String.valueOf(LocalDateTime.now()), + errorCode.getCode(), + message, + data + ); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationController.java new file mode 100644 index 00000000..49c693f5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationController.java @@ -0,0 +1,92 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationResponseDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestResponseDto; +import com.dreamteam.alter.application.aop.AppActionContext; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.port.inbound.*; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@PreAuthorize("hasAnyRole('USER')") +@RequiredArgsConstructor +@Validated +@RequestMapping("/app") +public class UserWorkspaceInvitationController implements UserWorkspaceInvitationControllerSpec { + + @Resource(name = "sendJoinRequest") + private final SendJoinRequestUseCase sendJoinRequestUseCase; + + @Resource(name = "getMyJoinRequestList") + private final GetMyJoinRequestListUseCase getMyJoinRequestListUseCase; + + @Resource(name = "getMyInvitationList") + private final GetMyInvitationListUseCase getMyInvitationListUseCase; + + @Resource(name = "acceptWorkspaceInvitation") + private final AcceptWorkspaceInvitationUseCase acceptWorkspaceInvitationUseCase; + + @Resource(name = "declineWorkspaceInvitation") + private final DeclineWorkspaceInvitationUseCase declineWorkspaceInvitationUseCase; + + @Override + @PostMapping("/workspaces/{workspaceId}/join-requests") + public ResponseEntity> sendJoinRequest( + @PathVariable Long workspaceId + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + sendJoinRequestUseCase.execute(actor, workspaceId); + return ResponseEntity.status(HttpStatus.CREATED).body(CommonApiResponse.empty()); + } + + @Override + @GetMapping("/users/me/join-requests") + public ResponseEntity> getMyJoinRequestList( + CursorPageRequestDto cursorPageRequest, + MyJoinRequestListFilterDto filter + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + return ResponseEntity.ok(getMyJoinRequestListUseCase.execute(actor, filter, cursorPageRequest)); + } + + @Override + @GetMapping("/users/me/invitations") + public ResponseEntity> getMyInvitationList( + CursorPageRequestDto cursorPageRequest, + MyInvitationListFilterDto filter + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + return ResponseEntity.ok(getMyInvitationListUseCase.execute(actor, filter, cursorPageRequest)); + } + + @Override + @PostMapping("/users/me/invitations/{invitationId}/accept") + public ResponseEntity> acceptInvitation( + @PathVariable Long invitationId + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + acceptWorkspaceInvitationUseCase.execute(actor, invitationId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PostMapping("/users/me/invitations/{invitationId}/decline") + public ResponseEntity> declineInvitation( + @PathVariable Long invitationId + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + declineWorkspaceInvitationUseCase.execute(actor, invitationId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationControllerSpec.java new file mode 100644 index 00000000..be9619d9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationControllerSpec.java @@ -0,0 +1,73 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationResponseDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "USER - 업장 초대/합류 요청 API") +public interface UserWorkspaceInvitationControllerSpec { + + @Operation(summary = "알바생 - 업장 합류 요청 보내기", description = "특정 업장에 합류 요청을 보냅니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "합류 요청 성공") + }) + ResponseEntity> sendJoinRequest( + @PathVariable Long workspaceId + ); + + @Operation(summary = "알바생 - 내가 보낸 합류 요청 목록 조회", description = """ + 내가 보낸 합류 요청 목록을 커서 기반 페이지네이션으로 조회합니다. + + - `status` 필터 미입력 시 전체 상태 조회 (PENDING, APPROVED, REJECTED) + - `cursor` 미입력 시 첫 페이지 조회 + - 응답의 `page.cursor`를 다음 요청의 `cursor`로 사용 + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "합류 요청 목록 조회 성공") + }) + ResponseEntity> getMyJoinRequestList( + CursorPageRequestDto cursorPageRequest, + MyJoinRequestListFilterDto filter + ); + + @Operation(summary = "알바생 - 내가 받은 초대 목록 조회", description = """ + 나에게 온 업장 초대 목록을 커서 기반 페이지네이션으로 조회합니다. + + - `status` 필터 미입력 시 전체 상태 조회 (PENDING, ACCEPTED, DECLINED, EXPIRED) + - `cursor` 미입력 시 첫 페이지 조회 + - 응답의 `page.cursor`를 다음 요청의 `cursor`로 사용 + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "초대 목록 조회 성공") + }) + ResponseEntity> getMyInvitationList( + CursorPageRequestDto cursorPageRequest, + MyInvitationListFilterDto filter + ); + + @Operation(summary = "알바생 - 업장 초대 수락", description = "받은 업장 초대를 수락합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "초대 수락 성공") + }) + ResponseEntity> acceptInvitation( + @PathVariable Long invitationId + ); + + @Operation(summary = "알바생 - 업장 초대 거절", description = "받은 업장 초대를 거절합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "초대 거절 성공") + }) + ResponseEntity> declineInvitation( + @PathVariable Long invitationId + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationListFilterDto.java new file mode 100644 index 00000000..2f5b540b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationListFilterDto.java @@ -0,0 +1,31 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.dto; + +import com.dreamteam.alter.domain.workspace.type.BusinessInvitationStatus; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ParameterObject +@Schema(description = "내 초대 목록 필터 DTO") +public class MyInvitationListFilterDto { + + @Parameter(description = "상태 필터 (PENDING | ACCEPTED | DECLINED | EXPIRED), 미입력 시 전체 조회") + private BusinessInvitationStatus status; + + @Parameter(description = "조회 시작일 (ISO: 2026-03-01), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate from; + + @Parameter(description = "조회 종료일 (ISO: 2026-03-31, 해당일 포함), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate to; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationResponseDto.java new file mode 100644 index 00000000..b0497868 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationResponseDto.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.dto; + +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "내가 받은 업장 초대 응답 DTO") +public class MyInvitationResponseDto { + + @Schema(description = "초대 ID", example = "1") + private Long invitationId; + + @Schema(description = "업장명", example = "스타벅스 강남점") + private String businessName; + + @Schema(description = "초대 일시", example = "2026-03-01T10:00:00") + private LocalDateTime invitedAt; + + @Schema(description = "초대 만료 일시", example = "2026-03-08T10:00:00") + private LocalDateTime expiresAt; + + public static MyInvitationResponseDto from(BusinessInvitation invitation) { + return MyInvitationResponseDto.builder() + .invitationId(invitation.getId()) + .businessName(invitation.getWorkspace().getBusinessName()) + .invitedAt(invitation.getCreatedAt()) + .expiresAt(invitation.getExpiresAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestListFilterDto.java new file mode 100644 index 00000000..aceacc83 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestListFilterDto.java @@ -0,0 +1,31 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.dto; + +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ParameterObject +@Schema(description = "내 합류 요청 목록 필터 DTO") +public class MyJoinRequestListFilterDto { + + @Parameter(description = "상태 필터 (PENDING | APPROVED | REJECTED), 미입력 시 전체 조회") + private BusinessJoinRequestStatus status; + + @Parameter(description = "조회 시작일 (ISO: 2026-03-01), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate from; + + @Parameter(description = "조회 종료일 (ISO: 2026-03-31, 해당일 포함), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate to; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestResponseDto.java new file mode 100644 index 00000000..b99e8dd9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestResponseDto.java @@ -0,0 +1,41 @@ +package com.dreamteam.alter.adapter.inbound.general.workspace.dto; + +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "내가 보낸 합류 요청 응답 DTO") +public class MyJoinRequestResponseDto { + + @Schema(description = "합류 요청 ID", example = "1") + private Long joinRequestId; + + @Schema(description = "업장명", example = "스타벅스 강남점") + private String businessName; + + @Schema(description = "합류 요청 상태", example = "PENDING") + private BusinessJoinRequestStatus status; + + @Schema(description = "합류 요청 일시", example = "2026-03-01T10:00:00") + private LocalDateTime requestedAt; + + public static MyJoinRequestResponseDto from(BusinessJoinRequest request) { + return MyJoinRequestResponseDto.builder() + .joinRequestId(request.getId()) + .businessName(request.getWorkspace().getBusinessName()) + .status(request.getStatus()) + .requestedAt(request.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationController.java new file mode 100644 index 00000000..5711802b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationController.java @@ -0,0 +1,82 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.SendWorkspaceInvitationRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestResponseDto; +import com.dreamteam.alter.application.aop.ManagerActionContext; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.port.inbound.*; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/manager/workspaces") +@PreAuthorize("hasAnyRole('MANAGER')") +@RequiredArgsConstructor +@Validated +public class ManagerWorkspaceInvitationController implements ManagerWorkspaceInvitationControllerSpec { + + @Resource(name = "sendWorkspaceInvitation") + private final SendWorkspaceInvitationUseCase sendWorkspaceInvitationUseCase; + + @Resource(name = "getWorkspaceJoinRequestList") + private final GetWorkspaceJoinRequestListUseCase getWorkspaceJoinRequestListUseCase; + + @Resource(name = "approveJoinRequest") + private final ApproveJoinRequestUseCase approveJoinRequestUseCase; + + @Resource(name = "rejectJoinRequest") + private final RejectJoinRequestUseCase rejectJoinRequestUseCase; + + @Override + @PostMapping("/{workspaceId}/invitations") + public ResponseEntity> sendInvitation( + @PathVariable Long workspaceId, + @Valid @RequestBody SendWorkspaceInvitationRequestDto request + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + sendWorkspaceInvitationUseCase.execute(actor, workspaceId, request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @GetMapping("/{workspaceId}/join-requests") + public ResponseEntity> getJoinRequestList( + @PathVariable Long workspaceId, + CursorPageRequestDto cursorPageRequest, + WorkspaceJoinRequestListFilterDto filter + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + return ResponseEntity.ok(getWorkspaceJoinRequestListUseCase.execute(actor, workspaceId, filter, cursorPageRequest)); + } + + @Override + @PostMapping("/{workspaceId}/join-requests/{requestId}/approve") + public ResponseEntity> approveJoinRequest( + @PathVariable Long workspaceId, + @PathVariable Long requestId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + approveJoinRequestUseCase.execute(actor, workspaceId, requestId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @PostMapping("/{workspaceId}/join-requests/{requestId}/reject") + public ResponseEntity> rejectJoinRequest( + @PathVariable Long workspaceId, + @PathVariable Long requestId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + rejectJoinRequestUseCase.execute(actor, workspaceId, requestId); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationControllerSpec.java new file mode 100644 index 00000000..e5f8d5c9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/controller/ManagerWorkspaceInvitationControllerSpec.java @@ -0,0 +1,84 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.controller; + +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.SendWorkspaceInvitationRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "MANAGER - 업장 초대/합류 요청 관리 API") +public interface ManagerWorkspaceInvitationControllerSpec { + + @Operation( + summary = "매니저 - 직원 초대 발송", + description = """ + 여러 휴대폰 번호로 사용자들을 업장에 초대합니다. + + **All-or-Nothing 처리:** + - 모든 번호가 발송 가능한 경우에만 초대가 일괄 발송됩니다. + - 발송 불가 번호(미가입, 이미 근무중, 이미 초대중)가 1개라도 있으면 에러를 반환하며, 에러 응답의 data에 발송 불가 번호 목록이 포함됩니다. + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "초대 발송 완료"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 업장 (B008) | phoneNumbers가 비어 있음 | 발송 불가 번호 포함 (B001, data: 발송 불가 번호 목록)"), + @ApiResponse(responseCode = "403", description = "해당 업장의 관리자가 아님 (A002)") + }) + ResponseEntity> sendInvitation( + @PathVariable Long workspaceId, + @Valid @RequestBody SendWorkspaceInvitationRequestDto request + ); + + @Operation(summary = "매니저 - 업장 합류 요청 목록 조회", description = """ + 업장에 들어온 합류 요청 목록을 커서 기반 페이지네이션으로 조회합니다. + + - `status` 필터 미입력 시 전체 상태 조회 (PENDING, APPROVED, REJECTED) + - `cursor` 미입력 시 첫 페이지 조회 + - 응답의 `page.cursor`를 다음 요청의 `cursor`로 사용 + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "합류 요청 목록 조회 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 업장 (B008)"), + @ApiResponse(responseCode = "403", description = "해당 업장의 관리자가 아님 (A002)") + }) + ResponseEntity> getJoinRequestList( + @PathVariable Long workspaceId, + CursorPageRequestDto cursorPageRequest, + WorkspaceJoinRequestListFilterDto filter + ); + + @Operation(summary = "매니저 - 합류 요청 승인", description = "합류 요청을 승인하고 해당 사용자를 직원으로 등록합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "합류 요청 승인 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 업장 (B008) | 이미 근무 중인 사용자 (B018)"), + @ApiResponse(responseCode = "403", description = "해당 업장의 관리자가 아님 (A002) | 해당 업장의 합류 요청이 아님 (A002)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 합류 요청 (B019)"), + @ApiResponse(responseCode = "409", description = "승인할 수 없는 상태의 합류 요청 (B020)") + }) + ResponseEntity> approveJoinRequest( + @PathVariable Long workspaceId, + @PathVariable Long requestId + ); + + @Operation(summary = "매니저 - 합류 요청 거절", description = "합류 요청을 거절합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "합류 요청 거절 성공"), + @ApiResponse(responseCode = "400", description = "존재하지 않는 업장 (B008)"), + @ApiResponse(responseCode = "403", description = "해당 업장의 관리자가 아님 (A002) | 해당 업장의 합류 요청이 아님 (A002)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 합류 요청 (B019)"), + @ApiResponse(responseCode = "409", description = "거절할 수 없는 상태의 합류 요청 (B020)") + }) + ResponseEntity> rejectJoinRequest( + @PathVariable Long workspaceId, + @PathVariable Long requestId + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/SendWorkspaceInvitationRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/SendWorkspaceInvitationRequestDto.java new file mode 100644 index 00000000..aed775ff --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/SendWorkspaceInvitationRequestDto.java @@ -0,0 +1,20 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Getter +@NoArgsConstructor +@Schema(description = "직원 초대 요청 DTO") +public class SendWorkspaceInvitationRequestDto { + + @NotEmpty + @Schema(description = "초대할 직원의 휴대폰 번호 목록", example = "[\"01012345678\", \"01087654321\"]") + private Set<@NotBlank @Size(min = 10, max = 11) String> phoneNumbers; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestListFilterDto.java new file mode 100644 index 00000000..31976af8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestListFilterDto.java @@ -0,0 +1,31 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.dto; + +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@ParameterObject +@Schema(description = "업장 합류 요청 목록 필터 DTO") +public class WorkspaceJoinRequestListFilterDto { + + @Parameter(description = "상태 필터 (PENDING | APPROVED | REJECTED), 미입력 시 전체 조회") + private BusinessJoinRequestStatus status; + + @Parameter(description = "조회 시작일 (ISO: 2026-03-01), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate from; + + @Parameter(description = "조회 종료일 (ISO: 2026-03-31, 해당일 포함), 미입력 시 제한 없음") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate to; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestResponseDto.java new file mode 100644 index 00000000..97112dac --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/workspace/dto/WorkspaceJoinRequestResponseDto.java @@ -0,0 +1,45 @@ +package com.dreamteam.alter.adapter.inbound.manager.workspace.dto; + +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "업장 합류 요청 응답 DTO") +public class WorkspaceJoinRequestResponseDto { + + @Schema(description = "합류 요청 ID", example = "1") + private Long joinRequestId; + + @Schema(description = "요청자 이름", example = "김철수") + private String userName; + + @Schema(description = "요청자 휴대폰 번호", example = "01012345678") + private String userContact; + + @Schema(description = "합류 요청 상태", example = "PENDING") + private BusinessJoinRequestStatus status; + + @Schema(description = "합류 요청 일시", example = "2026-03-01T10:00:00") + private LocalDateTime requestedAt; + + public static WorkspaceJoinRequestResponseDto from(BusinessJoinRequest request) { + return WorkspaceJoinRequestResponseDto.builder() + .joinRequestId(request.getId()) + .userName(request.getUser().getName()) + .userContact(request.getUser().getContact()) + .status(request.getStatus()) + .requestedAt(request.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserQueryRepositoryImpl.java index 0c63f286..43bda003 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserQueryRepositoryImpl.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; @Repository @RequiredArgsConstructor @@ -116,4 +117,15 @@ public List findAllById(List ids) { ) .fetch(); } + + @Override + public List findByContactIn(Set contacts) { + QUser qUser = QUser.user; + return queryFactory.selectFrom(qUser) + .where( + qUser.contact.in(contacts), + qUser.status.eq(UserStatus.ACTIVE) + ) + .fetch(); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationJpaRepository.java new file mode 100644 index 00000000..0b6e1913 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BusinessInvitationJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationQueryRepositoryImpl.java new file mode 100644 index 00000000..40b487c4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationQueryRepositoryImpl.java @@ -0,0 +1,121 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequest; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.entity.QBusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import com.dreamteam.alter.domain.workspace.type.BusinessInvitationStatus; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Repository; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class BusinessInvitationQueryRepositoryImpl implements BusinessInvitationQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findById(Long id) { + QBusinessInvitation qBusinessInvitation = QBusinessInvitation.businessInvitation; + + return Optional.ofNullable( + queryFactory.selectFrom(qBusinessInvitation) + .where(qBusinessInvitation.id.eq(id)) + .fetchOne() + ); + } + + @Override + public Set findPendingInvitedUserIdsByUserIds(Long workspaceId, Set userIds) { + QBusinessInvitation qBusinessInvitation = QBusinessInvitation.businessInvitation; + + return new HashSet<>(queryFactory + .select(qBusinessInvitation.invitedUser.id) + .from(qBusinessInvitation) + .where( + qBusinessInvitation.workspace.id.eq(workspaceId), + qBusinessInvitation.status.eq(BusinessInvitationStatus.PENDING), + qBusinessInvitation.invitedUser.id.in(userIds) + ) + .fetch()); + } + + @Override + public long countByUser(User user, MyInvitationListFilterDto filter) { + QBusinessInvitation q = QBusinessInvitation.businessInvitation; + + Long count = queryFactory + .select(q.count()) + .from(q) + .where( + q.invitedUser.eq(user), + statusCondition(q, filter), + dateFromCondition(q, filter), + dateToCondition(q, filter) + ) + .fetchOne(); + + return ObjectUtils.isNotEmpty(count) ? count : 0; + } + + @Override + public List findByUserWithCursor(CursorPageRequest pageRequest, User user, MyInvitationListFilterDto filter) { + QBusinessInvitation q = QBusinessInvitation.businessInvitation; + + return queryFactory.selectFrom(q) + .join(q.workspace).fetchJoin() + .where( + q.invitedUser.eq(user), + statusCondition(q, filter), + dateFromCondition(q, filter), + dateToCondition(q, filter), + cursorCondition(q, pageRequest.cursor()) + ) + .orderBy(q.createdAt.desc(), q.id.desc()) + .limit(pageRequest.pageSize()) + .fetch(); + } + + private BooleanExpression statusCondition(QBusinessInvitation q, MyInvitationListFilterDto filter) { + if (filter == null || filter.getStatus() == null) { + return null; + } + return q.status.eq(filter.getStatus()); + } + + private BooleanExpression dateFromCondition(QBusinessInvitation q, MyInvitationListFilterDto filter) { + if (filter == null || filter.getFrom() == null) { + return null; + } + return q.createdAt.goe(filter.getFrom().atStartOfDay()); + } + + private BooleanExpression dateToCondition(QBusinessInvitation q, MyInvitationListFilterDto filter) { + if (filter == null || filter.getTo() == null) { + return null; + } + return q.createdAt.lt(filter.getTo().plusDays(1).atStartOfDay()); + } + + private BooleanExpression cursorCondition(QBusinessInvitation q, CursorDto cursor) { + if (cursor == null || cursor.getId() == null) { + return null; + } + if (cursor.getCreatedAt() != null) { + return q.createdAt.lt(cursor.getCreatedAt()) + .or(q.createdAt.eq(cursor.getCreatedAt()).and(q.id.lt(cursor.getId()))); + } + return q.id.lt(cursor.getId()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationRepositoryImpl.java new file mode 100644 index 00000000..170b16d4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessInvitationRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class BusinessInvitationRepositoryImpl implements BusinessInvitationRepository { + + private final BusinessInvitationJpaRepository businessInvitationJpaRepository; + + @Override + public void save(BusinessInvitation invitation) { + businessInvitationJpaRepository.save(invitation); + } + + @Override + public void saveAll(List invitations) { + businessInvitationJpaRepository.saveAll(invitations); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestJpaRepository.java new file mode 100644 index 00000000..bfe4838e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BusinessJoinRequestJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestQueryRepositoryImpl.java new file mode 100644 index 00000000..a65f00db --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestQueryRepositoryImpl.java @@ -0,0 +1,149 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequest; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.QBusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BusinessJoinRequestQueryRepositoryImpl implements BusinessJoinRequestQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findById(Long id) { + QBusinessJoinRequest qBusinessJoinRequest = QBusinessJoinRequest.businessJoinRequest; + + return Optional.ofNullable( + queryFactory.selectFrom(qBusinessJoinRequest) + .where(qBusinessJoinRequest.id.eq(id)) + .fetchOne() + ); + } + + @Override + public boolean existsPendingRequest(Workspace workspace, User user) { + QBusinessJoinRequest qBusinessJoinRequest = QBusinessJoinRequest.businessJoinRequest; + + Long count = queryFactory + .select(qBusinessJoinRequest.count()) + .from(qBusinessJoinRequest) + .where(qBusinessJoinRequest.workspace.eq(workspace) + .and(qBusinessJoinRequest.user.eq(user)) + .and(qBusinessJoinRequest.status.eq(BusinessJoinRequestStatus.PENDING))) + .fetchOne(); + + return count != null && count > 0; + } + + @Override + public long countByUser(User user, MyJoinRequestListFilterDto filter) { + QBusinessJoinRequest q = QBusinessJoinRequest.businessJoinRequest; + + Long count = queryFactory + .select(q.count()) + .from(q) + .where( + q.user.eq(user), + statusCondition(q, filter != null ? filter.getStatus() : null), + dateFromCondition(q, filter != null ? filter.getFrom() : null), + dateToCondition(q, filter != null ? filter.getTo() : null) + ) + .fetchOne(); + + return ObjectUtils.isNotEmpty(count) ? count : 0; + } + + @Override + public long countByWorkspace(Workspace workspace, WorkspaceJoinRequestListFilterDto filter) { + QBusinessJoinRequest q = QBusinessJoinRequest.businessJoinRequest; + + Long count = queryFactory + .select(q.count()) + .from(q) + .where( + q.workspace.eq(workspace), + statusCondition(q, filter != null ? filter.getStatus() : null), + dateFromCondition(q, filter != null ? filter.getFrom() : null), + dateToCondition(q, filter != null ? filter.getTo() : null) + ) + .fetchOne(); + + return ObjectUtils.isNotEmpty(count) ? count : 0; + } + + @Override + public List findByUserWithCursor(CursorPageRequest pageRequest, User user, MyJoinRequestListFilterDto filter) { + QBusinessJoinRequest q = QBusinessJoinRequest.businessJoinRequest; + + return queryFactory.selectFrom(q) + .join(q.workspace).fetchJoin() + .where( + q.user.eq(user), + statusCondition(q, filter != null ? filter.getStatus() : null), + dateFromCondition(q, filter != null ? filter.getFrom() : null), + dateToCondition(q, filter != null ? filter.getTo() : null), + cursorCondition(q, pageRequest.cursor()) + ) + .orderBy(q.createdAt.desc(), q.id.desc()) + .limit(pageRequest.pageSize()) + .fetch(); + } + + @Override + public List findByWorkspaceWithCursor(CursorPageRequest pageRequest, Workspace workspace, WorkspaceJoinRequestListFilterDto filter) { + QBusinessJoinRequest q = QBusinessJoinRequest.businessJoinRequest; + + return queryFactory.selectFrom(q) + .join(q.user).fetchJoin() + .where( + q.workspace.eq(workspace), + statusCondition(q, filter != null ? filter.getStatus() : null), + dateFromCondition(q, filter != null ? filter.getFrom() : null), + dateToCondition(q, filter != null ? filter.getTo() : null), + cursorCondition(q, pageRequest.cursor()) + ) + .orderBy(q.createdAt.desc(), q.id.desc()) + .limit(pageRequest.pageSize()) + .fetch(); + } + + private BooleanExpression statusCondition(QBusinessJoinRequest q, BusinessJoinRequestStatus status) { + return status != null ? q.status.eq(status) : null; + } + + private BooleanExpression dateFromCondition(QBusinessJoinRequest q, LocalDate from) { + return from != null ? q.createdAt.goe(from.atStartOfDay()) : null; + } + + private BooleanExpression dateToCondition(QBusinessJoinRequest q, LocalDate to) { + return to != null ? q.createdAt.lt(to.plusDays(1).atStartOfDay()) : null; + } + + private BooleanExpression cursorCondition(QBusinessJoinRequest q, CursorDto cursor) { + if (cursor == null || cursor.getId() == null) { + return null; + } + if (cursor.getCreatedAt() != null) { + return q.createdAt.lt(cursor.getCreatedAt()) + .or(q.createdAt.eq(cursor.getCreatedAt()).and(q.id.lt(cursor.getId()))); + } + return q.id.lt(cursor.getId()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestRepositoryImpl.java new file mode 100644 index 00000000..fc283101 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/BusinessJoinRequestRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.outbound.workspace.persistence; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BusinessJoinRequestRepositoryImpl implements BusinessJoinRequestRepository { + + private final BusinessJoinRequestJpaRepository businessJoinRequestJpaRepository; + + @Override + public void save(BusinessJoinRequest request) { + try { + businessJoinRequestJpaRepository.saveAndFlush(request); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.CONFLICT, "이미 합류 요청이 진행 중인 업장입니다."); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java index 431d8aa8..c0de8437 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/workspace/persistence/WorkspaceQueryRepositoryImpl.java @@ -2,8 +2,10 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Repository; @@ -562,6 +564,35 @@ public List findAllForNextMonthShiftGeneration(int day, boolean isLas .fetch(); } + @Override + public Set findActiveWorkerUserIds(Long workspaceId) { + QWorkspaceWorker qWorkspaceWorker = QWorkspaceWorker.workspaceWorker; + + return new HashSet<>(queryFactory + .select(qWorkspaceWorker.user.id) + .from(qWorkspaceWorker) + .where( + qWorkspaceWorker.workspace.id.eq(workspaceId), + qWorkspaceWorker.status.eq(WorkspaceWorkerStatus.ACTIVATED) + ) + .fetch()); + } + + @Override + public Set findActiveWorkerUserIdsByUserIds(Long workspaceId, Set userIds) { + QWorkspaceWorker qWorkspaceWorker = QWorkspaceWorker.workspaceWorker; + + return new HashSet<>(queryFactory + .select(qWorkspaceWorker.user.id) + .from(qWorkspaceWorker) + .where( + qWorkspaceWorker.workspace.id.eq(workspaceId), + qWorkspaceWorker.status.eq(WorkspaceWorkerStatus.ACTIVATED), + qWorkspaceWorker.user.id.in(userIds) + ) + .fetch()); + } + @Override public boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser) { QWorkspace qWorkspace = QWorkspace.workspace; diff --git a/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEvent.java b/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEvent.java new file mode 100644 index 00000000..bebef401 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEvent.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.application.notification; + +import com.dreamteam.alter.adapter.inbound.common.dto.FcmNotificationRequestDto; + +public record FcmNotificationEvent(FcmNotificationRequestDto request) { +} diff --git a/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEventListener.java b/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEventListener.java new file mode 100644 index 00000000..9ccd7f51 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/notification/FcmNotificationEventListener.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.application.notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmNotificationEventListener { + + private final NotificationService notificationService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleFcmNotification(FcmNotificationEvent event) { + try { + notificationService.sendNotification(event.request()); + } catch (Exception e) { + log.warn("FCM 알림 발송 실패. targetUserId={}, error={}", + event.request().getTargetUserId(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitation.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitation.java new file mode 100644 index 00000000..41eff345 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitation.java @@ -0,0 +1,39 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.inbound.AcceptWorkspaceInvitationUseCase; +import com.dreamteam.alter.domain.workspace.port.inbound.CreateWorkspaceWorkerUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("acceptWorkspaceInvitation") +@RequiredArgsConstructor +@Transactional +public class AcceptWorkspaceInvitation implements AcceptWorkspaceInvitationUseCase { + + private final BusinessInvitationQueryRepository businessInvitationQueryRepository; + private final CreateWorkspaceWorkerUseCase addWorkerToWorkspace; + + @Override + public void execute(AppActor actor, Long invitationId) { + BusinessInvitation invitation = businessInvitationQueryRepository.findById(invitationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "존재하지 않는 초대입니다.")); + + if (!invitation.getInvitedUser().getId().equals(actor.getUserId())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 초대의 수신자가 아닙니다."); + } + + if (invitation.isExpired()) { + throw new CustomException(ErrorCode.CONFLICT, "만료된 초대입니다."); + } + + invitation.accept(); + + addWorkerToWorkspace.execute(invitation.getWorkspace(), actor.getUser()); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequest.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequest.java new file mode 100644 index 00000000..9875a56f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequest.java @@ -0,0 +1,60 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FcmNotificationRequestDto; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.notification.NotificationMessageConstants; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.ApproveJoinRequestUseCase; +import com.dreamteam.alter.domain.workspace.port.inbound.CreateWorkspaceWorkerUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("approveJoinRequest") +@RequiredArgsConstructor +@Transactional +public class ApproveJoinRequest implements ApproveJoinRequestUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + private final ApplicationEventPublisher eventPublisher; + private final CreateWorkspaceWorkerUseCase addWorkerToWorkspace; + + @Override + public void execute(ManagerActor actor, Long workspaceId, Long requestId) { + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + + if (!workspaceQueryRepository.existsByIdAndManagerUser(workspaceId, actor.getManagerUser())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 관리자가 아닙니다."); + } + + BusinessJoinRequest joinRequest = businessJoinRequestQueryRepository.findById(requestId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "존재하지 않는 합류 요청입니다.")); + + if (!joinRequest.getWorkspace().getId().equals(workspaceId)) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 합류 요청이 아닙니다."); + } + + joinRequest.approve(); + + addWorkerToWorkspace.execute(workspace, joinRequest.getUser()); + + String title = NotificationMessageConstants.JoinRequest.REQUEST_APPROVED_TITLE; + String body = String.format( + NotificationMessageConstants.JoinRequest.REQUEST_APPROVED_BODY, + workspace.getBusinessName() + ); + eventPublisher.publishEvent( + new FcmNotificationEvent(FcmNotificationRequestDto.of(joinRequest.getUser().getId(), TokenScope.APP, title, body)) + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitation.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitation.java new file mode 100644 index 00000000..4d1b7f05 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitation.java @@ -0,0 +1,31 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.inbound.DeclineWorkspaceInvitationUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("declineWorkspaceInvitation") +@RequiredArgsConstructor +@Transactional +public class DeclineWorkspaceInvitation implements DeclineWorkspaceInvitationUseCase { + + private final BusinessInvitationQueryRepository businessInvitationQueryRepository; + + @Override + public void execute(AppActor actor, Long invitationId) { + BusinessInvitation invitation = businessInvitationQueryRepository.findById(invitationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "존재하지 않는 초대입니다.")); + + if (!invitation.getInvitedUser().getId().equals(actor.getUserId())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 초대의 수신자가 아닙니다."); + } + + invitation.decline(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyInvitationList.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyInvitationList.java new file mode 100644 index 00000000..715d1ecc --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyInvitationList.java @@ -0,0 +1,59 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.*; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationResponseDto; +import com.dreamteam.alter.common.util.CursorUtil; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.inbound.GetMyInvitationListUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("getMyInvitationList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetMyInvitationList implements GetMyInvitationListUseCase { + + private final BusinessInvitationQueryRepository businessInvitationQueryRepository; + private final ObjectMapper objectMapper; + + @Override + public CursorPaginatedApiResponse execute(AppActor actor, MyInvitationListFilterDto filter, CursorPageRequestDto cursorPageRequest) { + CursorDto cursorDto = null; + if (ObjectUtils.isNotEmpty(cursorPageRequest.cursor())) { + cursorDto = CursorUtil.decodeCursor(cursorPageRequest.cursor(), CursorDto.class, objectMapper); + } + CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, cursorPageRequest.pageSize()); + + long count = businessInvitationQueryRepository.countByUser(actor.getUser(), filter); + if (count == 0) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + List invitations = businessInvitationQueryRepository.findByUserWithCursor(pageRequest, actor.getUser(), filter); + if (ObjectUtils.isEmpty(invitations)) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + BusinessInvitation last = invitations.getLast(); + CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( + CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper), + pageRequest.pageSize(), + (int) count + ); + + return CursorPaginatedApiResponse.of( + pageResponseDto, + invitations.stream() + .map(MyInvitationResponseDto::from) + .toList() + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyJoinRequestList.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyJoinRequestList.java new file mode 100644 index 00000000..fe6a772a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetMyJoinRequestList.java @@ -0,0 +1,59 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.*; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestResponseDto; +import com.dreamteam.alter.common.util.CursorUtil; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.port.inbound.GetMyJoinRequestListUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("getMyJoinRequestList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetMyJoinRequestList implements GetMyJoinRequestListUseCase { + + private final BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + private final ObjectMapper objectMapper; + + @Override + public CursorPaginatedApiResponse execute(AppActor actor, MyJoinRequestListFilterDto filter, CursorPageRequestDto cursorPageRequest) { + CursorDto cursorDto = null; + if (ObjectUtils.isNotEmpty(cursorPageRequest.cursor())) { + cursorDto = CursorUtil.decodeCursor(cursorPageRequest.cursor(), CursorDto.class, objectMapper); + } + CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, cursorPageRequest.pageSize()); + + long count = businessJoinRequestQueryRepository.countByUser(actor.getUser(), filter); + if (count == 0) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + List requests = businessJoinRequestQueryRepository.findByUserWithCursor(pageRequest, actor.getUser(), filter); + if (ObjectUtils.isEmpty(requests)) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + BusinessJoinRequest last = requests.getLast(); + CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( + CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper), + pageRequest.pageSize(), + (int) count + ); + + return CursorPaginatedApiResponse.of( + pageResponseDto, + requests.stream() + .map(MyJoinRequestResponseDto::from) + .toList() + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetWorkspaceJoinRequestList.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetWorkspaceJoinRequestList.java new file mode 100644 index 00000000..4b034579 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/GetWorkspaceJoinRequestList.java @@ -0,0 +1,71 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.*; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestResponseDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.util.CursorUtil; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.GetWorkspaceJoinRequestListUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("getWorkspaceJoinRequestList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetWorkspaceJoinRequestList implements GetWorkspaceJoinRequestListUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + private final ObjectMapper objectMapper; + + @Override + public CursorPaginatedApiResponse execute(ManagerActor actor, Long workspaceId, WorkspaceJoinRequestListFilterDto filter, CursorPageRequestDto cursorPageRequest) { + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + + if (!workspaceQueryRepository.existsByIdAndManagerUser(workspaceId, actor.getManagerUser())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 관리자가 아닙니다."); + } + + CursorDto cursorDto = null; + if (ObjectUtils.isNotEmpty(cursorPageRequest.cursor())) { + cursorDto = CursorUtil.decodeCursor(cursorPageRequest.cursor(), CursorDto.class, objectMapper); + } + CursorPageRequest pageRequest = CursorPageRequest.of(cursorDto, cursorPageRequest.pageSize()); + + long count = businessJoinRequestQueryRepository.countByWorkspace(workspace, filter); + if (count == 0) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + List requests = businessJoinRequestQueryRepository.findByWorkspaceWithCursor(pageRequest, workspace, filter); + if (ObjectUtils.isEmpty(requests)) { + return CursorPaginatedApiResponse.empty(CursorPageResponseDto.empty(cursorPageRequest.pageSize(), (int) count)); + } + + BusinessJoinRequest last = requests.getLast(); + CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( + CursorUtil.encodeCursor(new CursorDto(last.getId(), last.getCreatedAt()), objectMapper), + pageRequest.pageSize(), + (int) count + ); + + return CursorPaginatedApiResponse.of( + pageResponseDto, + requests.stream() + .map(WorkspaceJoinRequestResponseDto::from) + .toList() + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequest.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequest.java new file mode 100644 index 00000000..b746f1f5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequest.java @@ -0,0 +1,56 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FcmNotificationRequestDto; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.notification.NotificationMessageConstants; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.RejectJoinRequestUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("rejectJoinRequest") +@RequiredArgsConstructor +@Transactional +public class RejectJoinRequest implements RejectJoinRequestUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + private final ApplicationEventPublisher eventPublisher; + + @Override + public void execute(ManagerActor actor, Long workspaceId, Long requestId) { + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + + if (!workspaceQueryRepository.existsByIdAndManagerUser(workspaceId, actor.getManagerUser())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 관리자가 아닙니다."); + } + + BusinessJoinRequest joinRequest = businessJoinRequestQueryRepository.findById(requestId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "존재하지 않는 합류 요청입니다.")); + + if (!joinRequest.getWorkspace().getId().equals(workspaceId)) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 합류 요청이 아닙니다."); + } + + joinRequest.reject(); + + String title = NotificationMessageConstants.JoinRequest.REQUEST_REJECTED_TITLE; + String body = String.format( + NotificationMessageConstants.JoinRequest.REQUEST_REJECTED_BODY, + workspace.getBusinessName() + ); + eventPublisher.publishEvent( + new FcmNotificationEvent(FcmNotificationRequestDto.of(joinRequest.getUser().getId(), TokenScope.APP, title, body)) + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequest.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequest.java new file mode 100644 index 00000000..ae6dae11 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequest.java @@ -0,0 +1,59 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FcmNotificationRequestDto; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.notification.NotificationMessageConstants; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.SendJoinRequestUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service("sendJoinRequest") +@RequiredArgsConstructor +@Transactional +public class SendJoinRequest implements SendJoinRequestUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + private final BusinessJoinRequestRepository businessJoinRequestRepository; + private final ApplicationEventPublisher eventPublisher; + + @Override + public void execute(AppActor actor, Long workspaceId) { + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + + if (workspaceQueryRepository.isUserActiveWorkerInWorkspace(actor.getUser(), workspaceId)) { + throw new CustomException(ErrorCode.WORKSPACE_WORKER_ALREADY_EXISTS); + } + + if (businessJoinRequestQueryRepository.existsPendingRequest(workspace, actor.getUser())) { + throw new CustomException(ErrorCode.CONFLICT, "이미 합류 요청이 진행 중인 업장입니다."); + } + + BusinessJoinRequest joinRequest = BusinessJoinRequest.create(workspace, actor.getUser()); + businessJoinRequestRepository.save(joinRequest); + + String title = NotificationMessageConstants.JoinRequest.REQUEST_RECEIVED_TITLE; + String body = String.format( + NotificationMessageConstants.JoinRequest.REQUEST_RECEIVED_BODY, + actor.getUser().getName() + ); + Long managerUserId = workspace.getManagerUser().getUser().getId(); + eventPublisher.publishEvent( + new FcmNotificationEvent(FcmNotificationRequestDto.of(managerUserId, TokenScope.MANAGER, title, body)) + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitation.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitation.java new file mode 100644 index 00000000..0ea0d064 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitation.java @@ -0,0 +1,99 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.common.dto.FcmNotificationRequestDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.SendWorkspaceInvitationRequestDto; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.notification.NotificationMessageConstants; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.exception.InvitationUnavailableException; +import com.dreamteam.alter.domain.workspace.port.inbound.SendWorkspaceInvitationUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service("sendWorkspaceInvitation") +@RequiredArgsConstructor +@Transactional +public class SendWorkspaceInvitation implements SendWorkspaceInvitationUseCase { + + private final WorkspaceQueryRepository workspaceQueryRepository; + private final UserQueryRepository userQueryRepository; + private final BusinessInvitationRepository businessInvitationRepository; + private final BusinessInvitationQueryRepository businessInvitationQueryRepository; + private final ApplicationEventPublisher eventPublisher; + + @Override + public void execute(ManagerActor actor, Long workspaceId, SendWorkspaceInvitationRequestDto request) { + Workspace workspace = workspaceQueryRepository.findById(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); + + if (!workspaceQueryRepository.existsByIdAndManagerUser(workspaceId, actor.getManagerUser())) { + throw new CustomException(ErrorCode.FORBIDDEN, "해당 업장의 관리자가 아닙니다."); + } + + Set phoneNumbers = request.getPhoneNumbers(); + Map contactToUser = userQueryRepository.findByContactIn(phoneNumbers) + .stream().collect(Collectors.toMap(User::getContact, Function.identity())); + + Set registeredUserIds = contactToUser.values().stream() + .map(User::getId) + .collect(Collectors.toSet()); + + Set activeWorkerUserIds = workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(workspaceId, registeredUserIds); + Set pendingInvitedUserIds = businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(workspaceId, registeredUserIds); + + List unavailablePhoneNumbers = new ArrayList<>(); + List invitationsToSave = new ArrayList<>(); + + for (String phoneNumber : phoneNumbers) { + User invitedUser = contactToUser.get(phoneNumber); + + if (invitedUser == null + || activeWorkerUserIds.contains(invitedUser.getId()) + || pendingInvitedUserIds.contains(invitedUser.getId())) { + unavailablePhoneNumbers.add(phoneNumber); + continue; + } + + invitationsToSave.add(BusinessInvitation.create(workspace, invitedUser, actor.getManagerUser())); + } + + if (!unavailablePhoneNumbers.isEmpty()) { + throw new InvitationUnavailableException(unavailablePhoneNumbers); + } + + businessInvitationRepository.saveAll(invitationsToSave); + invitationsToSave.forEach(inv -> sendInvitationNotification(workspace, inv.getInvitedUser())); + } + + private void sendInvitationNotification(Workspace workspace, User invitedUser) { + String title = NotificationMessageConstants.WorkspaceInvitation.INVITATION_RECEIVED_TITLE; + String body = String.format( + NotificationMessageConstants.WorkspaceInvitation.INVITATION_RECEIVED_BODY, + workspace.getBusinessName() + ); + eventPublisher.publishEvent( + new FcmNotificationEvent(FcmNotificationRequestDto.of(invitedUser.getId(), TokenScope.APP, title, body)) + ); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java index 78fcf2ce..4ed3b6aa 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/dreamteam/alter/common/exception/handler/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.exception.FieldErrorDetail; import com.dreamteam.alter.domain.auth.exception.SignupRequiredException; +import com.dreamteam.alter.domain.workspace.exception.InvitationUnavailableException; import jakarta.validation.ConstraintViolationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -30,6 +31,12 @@ public ResponseEntity> handleSignupRequi .body(ErrorResponse.of(e.getErrorCode(), e.getSignupSessionResponseDto())); } + @ExceptionHandler(InvitationUnavailableException.class) + public ResponseEntity>> handleInvitationUnavailableException(InvitationUnavailableException e) { + return ResponseEntity.status(e.getErrorCode().getStatus()) + .body(ErrorResponse.of(e.getErrorCode(), e.getMessage(), e.getUnavailablePhoneNumbers())); + } + @ExceptionHandler(CustomException.class) public ResponseEntity> handleCustomException(CustomException e) { return ResponseEntity.status(e.getErrorCode().getStatus()) diff --git a/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java b/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java index 4acfbdc5..d4f88b3d 100644 --- a/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java +++ b/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java @@ -67,5 +67,25 @@ public static final class Chat { public static final String NEW_MESSAGE_TITLE = "새로운 메시지"; public static final String NEW_MESSAGE_BODY = "%s: %s"; } - + + /** + * 업장 초대 관련 알림 메시지 + */ + public static final class WorkspaceInvitation { + public static final String INVITATION_RECEIVED_TITLE = "업장 초대가 도착했어요!"; + public static final String INVITATION_RECEIVED_BODY = "%s 업장에서 회원님을 초대했습니다."; + } + + /** + * 합류 요청 관련 알림 메시지 + */ + public static final class JoinRequest { + public static final String REQUEST_RECEIVED_TITLE = "합류 요청이 도착했어요!"; + public static final String REQUEST_RECEIVED_BODY = "%s님이 합류 요청을 보냈습니다."; + public static final String REQUEST_APPROVED_TITLE = "합류 요청이 승인되었어요!"; + public static final String REQUEST_APPROVED_BODY = "%s 업장의 합류 요청이 승인되었습니다."; + public static final String REQUEST_REJECTED_TITLE = "합류 요청이 거절되었어요."; + public static final String REQUEST_REJECTED_BODY = "%s 업장의 합류 요청이 거절되었습니다."; + } + } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserQueryRepository.java index 18f8b485..27e7dc39 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserQueryRepository.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; public interface UserQueryRepository { Optional findById(Long id); @@ -13,4 +14,5 @@ public interface UserQueryRepository { Optional findByContact(String contact); Optional getUserSelfInfoSummary(Long id); List findAllById(List ids); + List findByContactIn(Set contacts); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessInvitation.java b/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessInvitation.java new file mode 100644 index 00000000..eb8312fb --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessInvitation.java @@ -0,0 +1,87 @@ +package com.dreamteam.alter.domain.workspace.entity; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.type.BusinessInvitationStatus; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "business_invitations") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class BusinessInvitation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workspace_id", nullable = false) + private Workspace workspace; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User invitedUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invited_by", nullable = false) + private ManagerUser invitedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private BusinessInvitationStatus status; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static BusinessInvitation create(Workspace workspace, User invitedUser, ManagerUser invitedBy) { + return BusinessInvitation.builder() + .workspace(workspace) + .invitedUser(invitedUser) + .invitedBy(invitedBy) + .status(BusinessInvitationStatus.PENDING) + .expiresAt(LocalDateTime.now().plusDays(7)) + .build(); + } + + public void accept() { + if (!BusinessInvitationStatus.PENDING.equals(this.status) || isExpired()) { + throw new CustomException(ErrorCode.CONFLICT, "수락할 수 없는 상태의 초대입니다."); + } + this.status = BusinessInvitationStatus.ACCEPTED; + } + + public void decline() { + if (!BusinessInvitationStatus.PENDING.equals(this.status) || isExpired()) { + throw new CustomException(ErrorCode.CONFLICT, "거절할 수 없는 상태의 초대입니다."); + } + this.status = BusinessInvitationStatus.DECLINED; + } + + public void expire() { + this.status = BusinessInvitationStatus.EXPIRED; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessJoinRequest.java b/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessJoinRequest.java new file mode 100644 index 00000000..baec2441 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/entity/BusinessJoinRequest.java @@ -0,0 +1,69 @@ +package com.dreamteam.alter.domain.workspace.entity; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.type.BusinessJoinRequestStatus; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "business_join_requests") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class BusinessJoinRequest { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workspace_id", nullable = false) + private Workspace workspace; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private BusinessJoinRequestStatus status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static BusinessJoinRequest create(Workspace workspace, User user) { + return BusinessJoinRequest.builder() + .workspace(workspace) + .user(user) + .status(BusinessJoinRequestStatus.PENDING) + .build(); + } + + public void approve() { + if (!BusinessJoinRequestStatus.PENDING.equals(this.status)) { + throw new CustomException(ErrorCode.CONFLICT, "승인할 수 없는 상태의 합류 요청입니다."); + } + this.status = BusinessJoinRequestStatus.APPROVED; + } + + public void reject() { + if (!BusinessJoinRequestStatus.PENDING.equals(this.status)) { + throw new CustomException(ErrorCode.CONFLICT, "거절할 수 없는 상태의 합류 요청입니다."); + } + this.status = BusinessJoinRequestStatus.REJECTED; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/exception/InvitationUnavailableException.java b/src/main/java/com/dreamteam/alter/domain/workspace/exception/InvitationUnavailableException.java new file mode 100644 index 00000000..25d4b99c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/exception/InvitationUnavailableException.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.domain.workspace.exception; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import lombok.Getter; + +import java.util.List; + +@Getter +public class InvitationUnavailableException extends CustomException { + + private final List unavailablePhoneNumbers; + + public InvitationUnavailableException(List unavailablePhoneNumbers) { + super(ErrorCode.ILLEGAL_ARGUMENT, "발송할 수 없는 전화번호가 포함되어 있습니다."); + this.unavailablePhoneNumbers = unavailablePhoneNumbers; + } + +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AcceptWorkspaceInvitationUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AcceptWorkspaceInvitationUseCase.java new file mode 100644 index 00000000..2ee8fae9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/AcceptWorkspaceInvitationUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface AcceptWorkspaceInvitationUseCase { + void execute(AppActor actor, Long invitationId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ApproveJoinRequestUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ApproveJoinRequestUseCase.java new file mode 100644 index 00000000..c3cc5611 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/ApproveJoinRequestUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface ApproveJoinRequestUseCase { + void execute(ManagerActor actor, Long workspaceId, Long requestId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/DeclineWorkspaceInvitationUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/DeclineWorkspaceInvitationUseCase.java new file mode 100644 index 00000000..652dfad9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/DeclineWorkspaceInvitationUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface DeclineWorkspaceInvitationUseCase { + void execute(AppActor actor, Long invitationId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyInvitationListUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyInvitationListUseCase.java new file mode 100644 index 00000000..9c0a36d0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyInvitationListUseCase.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationResponseDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface GetMyInvitationListUseCase { + CursorPaginatedApiResponse execute(AppActor actor, MyInvitationListFilterDto filter, CursorPageRequestDto cursorPageRequest); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyJoinRequestListUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyJoinRequestListUseCase.java new file mode 100644 index 00000000..31a524ce --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetMyJoinRequestListUseCase.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestResponseDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface GetMyJoinRequestListUseCase { + CursorPaginatedApiResponse execute(AppActor actor, MyJoinRequestListFilterDto filter, CursorPageRequestDto cursorPageRequest); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetWorkspaceJoinRequestListUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetWorkspaceJoinRequestListUseCase.java new file mode 100644 index 00000000..6436ec08 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/GetWorkspaceJoinRequestListUseCase.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestResponseDto; +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface GetWorkspaceJoinRequestListUseCase { + CursorPaginatedApiResponse execute(ManagerActor actor, Long workspaceId, WorkspaceJoinRequestListFilterDto filter, CursorPageRequestDto cursorPageRequest); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/RejectJoinRequestUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/RejectJoinRequestUseCase.java new file mode 100644 index 00000000..ab2cf5b8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/RejectJoinRequestUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface RejectJoinRequestUseCase { + void execute(ManagerActor actor, Long workspaceId, Long requestId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendJoinRequestUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendJoinRequestUseCase.java new file mode 100644 index 00000000..6f427a8a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendJoinRequestUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface SendJoinRequestUseCase { + void execute(AppActor actor, Long workspaceId); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendWorkspaceInvitationUseCase.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendWorkspaceInvitationUseCase.java new file mode 100644 index 00000000..47768412 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/inbound/SendWorkspaceInvitationUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.workspace.port.inbound; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.SendWorkspaceInvitationRequestDto; +import com.dreamteam.alter.domain.user.context.ManagerActor; + +public interface SendWorkspaceInvitationUseCase { + void execute(ManagerActor actor, Long workspaceId, SendWorkspaceInvitationRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationQueryRepository.java new file mode 100644 index 00000000..cccc6c0f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationQueryRepository.java @@ -0,0 +1,18 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequest; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyInvitationListFilterDto; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface BusinessInvitationQueryRepository { + Optional findById(Long id); + Set findPendingInvitedUserIdsByUserIds(Long workspaceId, Set userIds); + long countByUser(User user, MyInvitationListFilterDto filter); + List findByUserWithCursor(CursorPageRequest pageRequest, User user, MyInvitationListFilterDto filter); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationRepository.java new file mode 100644 index 00000000..0a30c76c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessInvitationRepository.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; + +import java.util.List; + +public interface BusinessInvitationRepository { + void save(BusinessInvitation invitation); + void saveAll(List invitations); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestQueryRepository.java new file mode 100644 index 00000000..ada66571 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestQueryRepository.java @@ -0,0 +1,21 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequest; +import com.dreamteam.alter.adapter.inbound.common.dto.CursorDto; +import com.dreamteam.alter.adapter.inbound.general.workspace.dto.MyJoinRequestListFilterDto; +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.WorkspaceJoinRequestListFilterDto; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; + +import java.util.List; +import java.util.Optional; + +public interface BusinessJoinRequestQueryRepository { + Optional findById(Long id); + boolean existsPendingRequest(Workspace workspace, User user); + long countByUser(User user, MyJoinRequestListFilterDto filter); + long countByWorkspace(Workspace workspace, WorkspaceJoinRequestListFilterDto filter); + List findByUserWithCursor(CursorPageRequest pageRequest, User user, MyJoinRequestListFilterDto filter); + List findByWorkspaceWithCursor(CursorPageRequest pageRequest, Workspace workspace, WorkspaceJoinRequestListFilterDto filter); +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestRepository.java new file mode 100644 index 00000000..585421e0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/BusinessJoinRequestRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.workspace.port.outbound; + +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; + +public interface BusinessJoinRequestRepository { + void save(BusinessJoinRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java index e74e9c27..59663e19 100644 --- a/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/workspace/port/outbound/WorkspaceQueryRepository.java @@ -16,6 +16,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; public interface WorkspaceQueryRepository { Optional findById(Long id); @@ -75,4 +76,6 @@ List getManagerWorkspaceManagerListWithCurs boolean existsByIdAndManagerUser(Long workspaceId, ManagerUser managerUser); List findAllForNextMonthShiftGeneration(int day, boolean isLastDayOfMonth); + Set findActiveWorkerUserIds(Long workspaceId); + Set findActiveWorkerUserIdsByUserIds(Long workspaceId, Set userIds); } diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessInvitationStatus.java b/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessInvitationStatus.java new file mode 100644 index 00000000..33b62bc5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessInvitationStatus.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.workspace.type; + +public enum BusinessInvitationStatus { + PENDING, ACCEPTED, DECLINED, EXPIRED +} diff --git a/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessJoinRequestStatus.java b/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessJoinRequestStatus.java new file mode 100644 index 00000000..f295f510 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/workspace/type/BusinessJoinRequestStatus.java @@ -0,0 +1,5 @@ +package com.dreamteam.alter.domain.workspace.type; + +public enum BusinessJoinRequestStatus { + PENDING, APPROVED, REJECTED +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitationTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitationTests.java new file mode 100644 index 00000000..1934b3b6 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AcceptWorkspaceInvitationTests.java @@ -0,0 +1,160 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.CreateWorkspaceWorkerUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AcceptWorkspaceInvitation 테스트") +class AcceptWorkspaceInvitationTests { + + @Mock private BusinessInvitationQueryRepository businessInvitationQueryRepository; + @Mock private CreateWorkspaceWorkerUseCase addWorkerToWorkspace; + + @InjectMocks + private AcceptWorkspaceInvitation acceptWorkspaceInvitation; + + private AppActor actor; + private User actorUser; + + @BeforeEach + void setUp() { + actorUser = mock(User.class); + actor = mock(AppActor.class); + given(actor.getUserId()).willReturn(1L); + given(actor.getUser()).willReturn(actorUser); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("존재하지 않는 초대 ID이면 NOT_FOUND 예외 발생") + void fails_whenInvitationNotFound() { + // given + given(businessInvitationQueryRepository.findById(99L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> acceptWorkspaceInvitation.execute(actor, 99L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("초대 수신자가 아닌 유저가 수락하면 FORBIDDEN 예외 발생") + void fails_whenNotInvitedUser() { + // given + User anotherUser = mock(User.class); + given(anotherUser.getId()).willReturn(999L); // actor userId=1L과 다름 + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(anotherUser); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + + // when & then + assertThatThrownBy(() -> acceptWorkspaceInvitation.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("만료된 초대를 수락하면 CONFLICT 예외 발생") + void fails_whenInvitationExpired() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(1L); + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(invitedUser); + given(invitation.isExpired()).willReturn(true); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + + // when & then + assertThatThrownBy(() -> acceptWorkspaceInvitation.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("이미 처리된 초대(ACCEPTED/DECLINED)를 수락하면 CONFLICT 예외 발생") + void fails_whenInvitationAlreadyProcessed() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(1L); + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(invitedUser); + given(invitation.isExpired()).willReturn(false); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + // accept()가 이미 처리된 상태라 내부에서 예외 발생 + org.mockito.BDDMockito.willThrow(new CustomException(ErrorCode.CONFLICT, "수락할 수 없는 상태의 초대입니다.")) + .given(invitation).accept(); + + // when & then + assertThatThrownBy(() -> acceptWorkspaceInvitation.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("정상 수락 시 워커 추가 UseCase 호출") + void succeeds_addsWorkerToWorkspace() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(1L); + + Workspace workspace = mock(Workspace.class); + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(invitedUser); + given(invitation.isExpired()).willReturn(false); + given(invitation.getWorkspace()).willReturn(workspace); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + + // when + acceptWorkspaceInvitation.execute(actor, 1L); + + // then + then(invitation).should().accept(); + then(addWorkerToWorkspace).should().execute(workspace, actorUser); + } + } + + // ArgumentCaptor 사용을 위한 any() 헬퍼 import 회피 + private static T any() { + return org.mockito.ArgumentMatchers.any(); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequestTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequestTests.java new file mode 100644 index 00000000..ed2b480b --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/ApproveJoinRequestTests.java @@ -0,0 +1,185 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.inbound.CreateWorkspaceWorkerUseCase; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ApproveJoinRequest 테스트") +class ApproveJoinRequestTests { + + @Mock private WorkspaceQueryRepository workspaceQueryRepository; + @Mock private BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + @Mock private ApplicationEventPublisher eventPublisher; + @Mock private CreateWorkspaceWorkerUseCase addWorkerToWorkspace; + + @InjectMocks + private ApproveJoinRequest approveJoinRequest; + + private ManagerActor actor; + private ManagerUser managerUser; + private Workspace workspace; + + @BeforeEach + void setUp() { + managerUser = mock(ManagerUser.class); + actor = mock(ManagerActor.class); + given(actor.getManagerUser()).willReturn(managerUser); + + workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(1L); + given(workspace.getBusinessName()).willReturn("테스트업장"); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("업장이 존재하지 않으면 WORKSPACE_NOT_FOUND 예외 발생") + void fails_whenWorkspaceNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> approveJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("해당 업장의 관리자가 아니면 FORBIDDEN 예외 발생") + void fails_whenNotWorkspaceManager() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(false); + + // when & then + assertThatThrownBy(() -> approveJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("존재하지 않는 합류 요청 ID이면 NOT_FOUND 예외 발생") + void fails_whenJoinRequestNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> approveJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("다른 업장의 합류 요청이면 FORBIDDEN 예외 발생") + void fails_whenJoinRequestBelongsToDifferentWorkspace() { + // given + Workspace otherWorkspace = mock(Workspace.class); + given(otherWorkspace.getId()).willReturn(999L); // workspaceId=1L과 다름 + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(otherWorkspace); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when & then + assertThatThrownBy(() -> approveJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("이미 처리된 합류 요청을 승인하면 CONFLICT 예외 발생") + void fails_whenJoinRequestAlreadyProcessed() { + // given + User requester = mock(User.class); + given(requester.getId()).willReturn(50L); + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(workspace); + given(joinRequest.getUser()).willReturn(requester); + willThrow(new CustomException(ErrorCode.CONFLICT, "승인할 수 없는 상태의 합류 요청입니다.")) + .given(joinRequest).approve(); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when & then + assertThatThrownBy(() -> approveJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + + then(addWorkerToWorkspace).should(never()).execute(any(), any()); + } + + @Test + @DisplayName("정상 승인 시 워커 추가 및 FCM 이벤트 발행") + void succeeds_addsWorkerAndPublishesEvent() { + // given + User requester = mock(User.class); + given(requester.getId()).willReturn(50L); + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(workspace); + given(joinRequest.getUser()).willReturn(requester); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when + approveJoinRequest.execute(actor, 1L, 10L); + + // then + then(joinRequest).should().approve(); + then(addWorkerToWorkspace).should().execute(workspace, requester); + then(eventPublisher).should().publishEvent(any(FcmNotificationEvent.class)); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitationTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitationTests.java new file mode 100644 index 00000000..84a47733 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/DeclineWorkspaceInvitationTests.java @@ -0,0 +1,119 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("DeclineWorkspaceInvitation 테스트") +class DeclineWorkspaceInvitationTests { + + @Mock private BusinessInvitationQueryRepository businessInvitationQueryRepository; + + @InjectMocks + private DeclineWorkspaceInvitation declineWorkspaceInvitation; + + private AppActor actor; + + @BeforeEach + void setUp() { + actor = mock(AppActor.class); + given(actor.getUserId()).willReturn(1L); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("존재하지 않는 초대 ID이면 NOT_FOUND 예외 발생") + void fails_whenInvitationNotFound() { + // given + given(businessInvitationQueryRepository.findById(99L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> declineWorkspaceInvitation.execute(actor, 99L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + } + + @Test + @DisplayName("초대 수신자가 아닌 유저가 거절하면 FORBIDDEN 예외 발생") + void fails_whenNotInvitedUser() { + // given + User anotherUser = mock(User.class); + given(anotherUser.getId()).willReturn(999L); + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(anotherUser); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + + // when & then + assertThatThrownBy(() -> declineWorkspaceInvitation.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(invitation).should(org.mockito.Mockito.never()).decline(); + } + + @Test + @DisplayName("이미 처리된 초대(ACCEPTED/EXPIRED)를 거절하면 CONFLICT 예외 발생") + void fails_whenInvitationAlreadyProcessed() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(1L); + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(invitedUser); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + willThrow(new CustomException(ErrorCode.CONFLICT, "거절할 수 없는 상태의 초대입니다.")) + .given(invitation).decline(); + + // when & then + assertThatThrownBy(() -> declineWorkspaceInvitation.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + } + + @Test + @DisplayName("정상 거절 시 decline() 호출") + void succeeds_callsDecline() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(1L); + + BusinessInvitation invitation = mock(BusinessInvitation.class); + given(invitation.getInvitedUser()).willReturn(invitedUser); + given(businessInvitationQueryRepository.findById(1L)).willReturn(Optional.of(invitation)); + + // when + declineWorkspaceInvitation.execute(actor, 1L); + + // then + then(invitation).should().decline(); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequestTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequestTests.java new file mode 100644 index 00000000..c288f8e1 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/RejectJoinRequestTests.java @@ -0,0 +1,182 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.BusinessJoinRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("RejectJoinRequest 테스트") +class RejectJoinRequestTests { + + @Mock private WorkspaceQueryRepository workspaceQueryRepository; + @Mock private BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private RejectJoinRequest rejectJoinRequest; + + private ManagerActor actor; + private ManagerUser managerUser; + private Workspace workspace; + + @BeforeEach + void setUp() { + managerUser = mock(ManagerUser.class); + actor = mock(ManagerActor.class); + given(actor.getManagerUser()).willReturn(managerUser); + + workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(1L); + given(workspace.getBusinessName()).willReturn("테스트업장"); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("업장이 존재하지 않으면 WORKSPACE_NOT_FOUND 예외 발생") + void fails_whenWorkspaceNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> rejectJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("해당 업장의 관리자가 아니면 FORBIDDEN 예외 발생") + void fails_whenNotWorkspaceManager() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(false); + + // when & then + assertThatThrownBy(() -> rejectJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("존재하지 않는 합류 요청 ID이면 NOT_FOUND 예외 발생") + void fails_whenJoinRequestNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> rejectJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.NOT_FOUND)); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("다른 업장의 합류 요청이면 FORBIDDEN 예외 발생") + void fails_whenJoinRequestBelongsToDifferentWorkspace() { + // given + Workspace otherWorkspace = mock(Workspace.class); + given(otherWorkspace.getId()).willReturn(999L); + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(otherWorkspace); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when & then + assertThatThrownBy(() -> rejectJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("이미 처리된 합류 요청을 거절하면 CONFLICT 예외 발생") + void fails_whenJoinRequestAlreadyProcessed() { + // given + User requester = mock(User.class); + given(requester.getId()).willReturn(50L); + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(workspace); + given(joinRequest.getUser()).willReturn(requester); + willThrow(new CustomException(ErrorCode.CONFLICT, "거절할 수 없는 상태의 합류 요청입니다.")) + .given(joinRequest).reject(); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when & then + assertThatThrownBy(() -> rejectJoinRequest.execute(actor, 1L, 10L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("정상 거절 시 reject() 호출 및 FCM 이벤트 발행") + void succeeds_rejectsAndPublishesEvent() { + // given + User requester = mock(User.class); + given(requester.getId()).willReturn(50L); + + BusinessJoinRequest joinRequest = mock(BusinessJoinRequest.class); + given(joinRequest.getWorkspace()).willReturn(workspace); + given(joinRequest.getUser()).willReturn(requester); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(businessJoinRequestQueryRepository.findById(10L)).willReturn(Optional.of(joinRequest)); + + // when + rejectJoinRequest.execute(actor, 1L, 10L); + + // then + then(joinRequest).should().reject(); + then(eventPublisher).should().publishEvent(any(FcmNotificationEvent.class)); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequestTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequestTests.java new file mode 100644 index 00000000..b3624fa7 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendJoinRequestTests.java @@ -0,0 +1,138 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessJoinRequestRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SendJoinRequest 테스트") +class SendJoinRequestTests { + + @Mock private WorkspaceQueryRepository workspaceQueryRepository; + @Mock private BusinessJoinRequestQueryRepository businessJoinRequestQueryRepository; + @Mock private BusinessJoinRequestRepository businessJoinRequestRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private SendJoinRequest sendJoinRequest; + + private AppActor actor; + private User actorUser; + private Workspace workspace; + + @BeforeEach + void setUp() { + actorUser = mock(User.class); + given(actorUser.getId()).willReturn(1L); + given(actorUser.getName()).willReturn("테스트유저"); + + actor = mock(AppActor.class); + given(actor.getUser()).willReturn(actorUser); + + User managerUser_user = mock(User.class); + given(managerUser_user.getId()).willReturn(100L); + + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(managerUser_user); + + workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(1L); + given(workspace.getBusinessName()).willReturn("테스트업장"); + given(workspace.getManagerUser()).willReturn(managerUser); + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("업장이 존재하지 않으면 WORKSPACE_NOT_FOUND 예외 발생") + void fails_whenWorkspaceNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sendJoinRequest.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + + then(businessJoinRequestRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미 활성 워커인 사용자가 요청하면 WORKSPACE_WORKER_ALREADY_EXISTS 예외 발생") + void fails_whenUserIsAlreadyActiveWorker() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.isUserActiveWorkerInWorkspace(actorUser, 1L)).willReturn(true); + + // when & then + assertThatThrownBy(() -> sendJoinRequest.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_WORKER_ALREADY_EXISTS)); + + then(businessJoinRequestRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미 대기 중인 합류 요청이 있으면 CONFLICT 예외 발생") + void fails_whenPendingRequestExists() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.isUserActiveWorkerInWorkspace(actorUser, 1L)).willReturn(false); + given(businessJoinRequestQueryRepository.existsPendingRequest(workspace, actorUser)).willReturn(true); + + // when & then + assertThatThrownBy(() -> sendJoinRequest.execute(actor, 1L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.CONFLICT)); + + then(businessJoinRequestRepository).should(never()).save(any()); + } + + @Test + @DisplayName("정상 합류 요청 시 요청 저장 및 FCM 이벤트 발행") + void succeeds_savesRequestAndPublishesEvent() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.isUserActiveWorkerInWorkspace(actorUser, 1L)).willReturn(false); + given(businessJoinRequestQueryRepository.existsPendingRequest(workspace, actorUser)).willReturn(false); + + // when + sendJoinRequest.execute(actor, 1L); + + // then + then(businessJoinRequestRepository).should().save(any()); + then(eventPublisher).should().publishEvent(any(FcmNotificationEvent.class)); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitationTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitationTests.java new file mode 100644 index 00000000..07e3100d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/SendWorkspaceInvitationTests.java @@ -0,0 +1,238 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.adapter.inbound.manager.workspace.dto.SendWorkspaceInvitationRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.ManagerActor; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.workspace.entity.BusinessInvitation; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.exception.InvitationUnavailableException; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.BusinessInvitationRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import com.dreamteam.alter.application.notification.FcmNotificationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SendWorkspaceInvitation 테스트") +class SendWorkspaceInvitationTests { + + @Mock private WorkspaceQueryRepository workspaceQueryRepository; + @Mock private UserQueryRepository userQueryRepository; + @Mock private BusinessInvitationRepository businessInvitationRepository; + @Mock private BusinessInvitationQueryRepository businessInvitationQueryRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private SendWorkspaceInvitation sendWorkspaceInvitation; + + private ManagerActor actor; + private ManagerUser managerUser; + private Workspace workspace; + + @BeforeEach + void setUp() { + managerUser = mock(ManagerUser.class); + actor = mock(ManagerActor.class); + given(actor.getManagerUser()).willReturn(managerUser); + + workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(1L); + given(workspace.getBusinessName()).willReturn("테스트업장"); + } + + private SendWorkspaceInvitationRequestDto requestOf(Set phoneNumbers) { + SendWorkspaceInvitationRequestDto dto = new SendWorkspaceInvitationRequestDto(); + ReflectionTestUtils.setField(dto, "phoneNumbers", phoneNumbers); + return dto; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("업장이 존재하지 않으면 WORKSPACE_NOT_FOUND 예외 발생") + void fails_whenWorkspaceNotFound() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.empty()); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01011111111")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.WORKSPACE_NOT_FOUND)); + + then(businessInvitationRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("해당 업장의 관리자가 아니면 FORBIDDEN 예외 발생") + void fails_whenNotWorkspaceManager() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(false); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01011111111")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN)); + + then(businessInvitationRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("앱에 가입되지 않은 번호가 포함되면 InvitationUnavailableException 발생") + void fails_whenPhoneNumberNotRegistered() { + // given + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(userQueryRepository.findByContactIn(Set.of("01099999999"))).willReturn(List.of()); + given(workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(any(), any())).willReturn(Set.of()); + given(businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(any(), any())).willReturn(Set.of()); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01099999999")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(InvitationUnavailableException.class) + .satisfies(ex -> { + InvitationUnavailableException invEx = (InvitationUnavailableException) ex; + assertThat(invEx.getUnavailablePhoneNumbers()).containsExactly("01099999999"); + }); + + then(businessInvitationRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("이미 활성 워커인 사용자 번호가 포함되면 InvitationUnavailableException 발생") + void fails_whenUserIsAlreadyActiveWorker() { + // given + User activeWorker = mock(User.class); + given(activeWorker.getId()).willReturn(10L); + given(activeWorker.getContact()).willReturn("01011111111"); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(userQueryRepository.findByContactIn(Set.of("01011111111"))).willReturn(List.of(activeWorker)); + given(workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(1L, Set.of(10L))).willReturn(Set.of(10L)); + given(businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(1L, Set.of(10L))).willReturn(Set.of()); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01011111111")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(InvitationUnavailableException.class) + .satisfies(ex -> { + InvitationUnavailableException invEx = (InvitationUnavailableException) ex; + assertThat(invEx.getUnavailablePhoneNumbers()).containsExactly("01011111111"); + }); + + then(businessInvitationRepository).should(never()).saveAll(any()); + } + + @Test + @DisplayName("이미 대기 중인 초대가 있는 사용자 번호가 포함되면 InvitationUnavailableException 발생") + void fails_whenPendingInvitationExists() { + // given + User pendingUser = mock(User.class); + given(pendingUser.getId()).willReturn(20L); + given(pendingUser.getContact()).willReturn("01022222222"); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(userQueryRepository.findByContactIn(Set.of("01022222222"))).willReturn(List.of(pendingUser)); + given(workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(1L, Set.of(20L))).willReturn(Set.of()); + given(businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(1L, Set.of(20L))).willReturn(Set.of(20L)); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01022222222")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(InvitationUnavailableException.class) + .satisfies(ex -> { + InvitationUnavailableException invEx = (InvitationUnavailableException) ex; + assertThat(invEx.getUnavailablePhoneNumbers()).containsExactly("01022222222"); + }); + } + + @Test + @DisplayName("정상 초대 발송 시 초대 저장 및 FCM 이벤트 발행") + void succeeds_savesInvitationsAndPublishesEvents() { + // given + User invitedUser = mock(User.class); + given(invitedUser.getId()).willReturn(30L); + given(invitedUser.getContact()).willReturn("01033333333"); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + given(userQueryRepository.findByContactIn(Set.of("01033333333"))).willReturn(List.of(invitedUser)); + given(workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(1L, Set.of(30L))).willReturn(Set.of()); + given(businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(1L, Set.of(30L))).willReturn(Set.of()); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01033333333")); + + // when + sendWorkspaceInvitation.execute(actor, 1L, request); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + then(businessInvitationRepository).should().saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(1); + then(eventPublisher).should().publishEvent(any(FcmNotificationEvent.class)); + } + + @Test + @DisplayName("여러 번호 중 하나라도 불가 번호가 있으면 전체 예외 발생 후 저장 없음") + void fails_whenAnyPhoneNumberIsUnavailable() { + // given + User validUser = mock(User.class); + given(validUser.getId()).willReturn(40L); + given(validUser.getContact()).willReturn("01044444444"); + + given(workspaceQueryRepository.findById(1L)).willReturn(Optional.of(workspace)); + given(workspaceQueryRepository.existsByIdAndManagerUser(1L, managerUser)).willReturn(true); + // 01099999999는 미가입 → contactToUser에서 조회 안 됨 + given(userQueryRepository.findByContactIn(any())).willReturn(List.of(validUser)); + given(workspaceQueryRepository.findActiveWorkerUserIdsByUserIds(any(), any())).willReturn(Set.of()); + given(businessInvitationQueryRepository.findPendingInvitedUserIdsByUserIds(any(), any())).willReturn(Set.of()); + SendWorkspaceInvitationRequestDto request = requestOf(Set.of("01044444444", "01099999999")); + + // when & then + assertThatThrownBy(() -> sendWorkspaceInvitation.execute(actor, 1L, request)) + .isInstanceOf(InvitationUnavailableException.class) + .satisfies(ex -> { + InvitationUnavailableException invEx = (InvitationUnavailableException) ex; + assertThat(invEx.getUnavailablePhoneNumbers()).containsExactly("01099999999"); + }); + + then(businessInvitationRepository).should(never()).saveAll(any()); + } + } +}