-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 직원 초대 및 합류 요청 기능 구현 #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
da4d76d
feat: 직원 초대 및 합류 요청 기능 구현
ba91f3e
feat: 합류요청 시 전화번호 검증 기준 추가
3ad4e54
feat: TOCTOU 경쟁 조건 취약점 수정
5b423b3
refactor: FCM 알림 발송을 트랜잭션 커밋 후 실행되도록 분리
a281d9c
refactor: 업장 초대시 전화번호 조회에 대한 N+1 문제 해결
44e9e68
refactor: Cursor Pagination 구성
cd89e0e
refactor: 요청/응답 DTO API 스팩 추가
764e9a4
refactor: 응답 DTO 기본 생성의 접근 제한자 PRIVATE으로 변경
9714dbf
SendWorkspaceInvitationRequestDto mager 패키지로 이동
e2e4f12
refactor: decline 메서드 isExpired 누락 수정
a85ba58
refactor: 초대 조회시 날짜와 상태 기준 필터링 조건 추가
3a32f03
refactor: 입력된 전화번호로 등록된 userIds를 먼저 추출 → IN 조건으로 범위를 좁혀 조회
ddf2635
refactor: private final 생성자 주입으로 변경
879f6e4
refactor: 동일번호 중복 방지를 위한 Set 타입으로 변경
e1259d8
refactor: 사용하지 않는 메서드 제거
c5ce62a
refactor: 목록 조회 UseCase에 필터 DTO 도입 및 totalCount 조회 개선
ed6f5b7
refactor: 사장님 초대 발송 로직 리팩터링
83409f4
refactor: Cursor Pagination 리팩터링
bbafa24
refactor: 파라미터 타입 일체화
4af93ac
refactor: 페이지네이션 리팩터링
2c7a431
test: 테스트케이스 작성
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,3 +52,4 @@ src/main/generated/ | |
| /.claude/ | ||
| CLAUDE.md | ||
| /.ai/ | ||
| .mcp.json | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
...alter/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CommonApiResponse<Void>> 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<CursorPaginatedApiResponse<MyJoinRequestResponseDto>> 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<CursorPaginatedApiResponse<MyInvitationResponseDto>> 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<CommonApiResponse<Void>> 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<CommonApiResponse<Void>> declineInvitation( | ||
| @PathVariable Long invitationId | ||
| ) { | ||
| AppActor actor = AppActionContext.getInstance().getActor(); | ||
| declineWorkspaceInvitationUseCase.execute(actor, invitationId); | ||
| return ResponseEntity.ok(CommonApiResponse.empty()); | ||
| } | ||
| } | ||
73 changes: 73 additions & 0 deletions
73
...r/adapter/inbound/general/workspace/controller/UserWorkspaceInvitationControllerSpec.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CommonApiResponse<Void>> sendJoinRequest( | ||
| @PathVariable Long workspaceId | ||
| ); | ||
|
|
||
| @Operation(summary = "알바생 - 내가 보낸 합류 요청 목록 조회", description = """ | ||
| 내가 보낸 합류 요청 목록을 커서 기반 페이지네이션으로 조회합니다. | ||
|
|
||
| - `status` 필터 미입력 시 전체 상태 조회 (PENDING, APPROVED, REJECTED) | ||
| - `cursor` 미입력 시 첫 페이지 조회 | ||
| - 응답의 `page.cursor`를 다음 요청의 `cursor`로 사용 | ||
| """) | ||
| @ApiResponses(value = { | ||
| @ApiResponse(responseCode = "200", description = "합류 요청 목록 조회 성공") | ||
| }) | ||
| ResponseEntity<CursorPaginatedApiResponse<MyJoinRequestResponseDto>> getMyJoinRequestList( | ||
| CursorPageRequestDto cursorPageRequest, | ||
| MyJoinRequestListFilterDto filter | ||
| ); | ||
|
|
||
| @Operation(summary = "알바생 - 내가 받은 초대 목록 조회", description = """ | ||
| 나에게 온 업장 초대 목록을 커서 기반 페이지네이션으로 조회합니다. | ||
|
|
||
| - `status` 필터 미입력 시 전체 상태 조회 (PENDING, ACCEPTED, DECLINED, EXPIRED) | ||
| - `cursor` 미입력 시 첫 페이지 조회 | ||
| - 응답의 `page.cursor`를 다음 요청의 `cursor`로 사용 | ||
| """) | ||
| @ApiResponses(value = { | ||
| @ApiResponse(responseCode = "200", description = "초대 목록 조회 성공") | ||
| }) | ||
| ResponseEntity<CursorPaginatedApiResponse<MyInvitationResponseDto>> getMyInvitationList( | ||
| CursorPageRequestDto cursorPageRequest, | ||
| MyInvitationListFilterDto filter | ||
| ); | ||
|
|
||
| @Operation(summary = "알바생 - 업장 초대 수락", description = "받은 업장 초대를 수락합니다.") | ||
| @ApiResponses(value = { | ||
| @ApiResponse(responseCode = "200", description = "초대 수락 성공") | ||
| }) | ||
| ResponseEntity<CommonApiResponse<Void>> acceptInvitation( | ||
| @PathVariable Long invitationId | ||
| ); | ||
|
|
||
| @Operation(summary = "알바생 - 업장 초대 거절", description = "받은 업장 초대를 거절합니다.") | ||
| @ApiResponses(value = { | ||
| @ApiResponse(responseCode = "200", description = "초대 거절 성공") | ||
| }) | ||
| ResponseEntity<CommonApiResponse<Void>> declineInvitation( | ||
| @PathVariable Long invitationId | ||
| ); | ||
| } |
31 changes: 31 additions & 0 deletions
31
.../com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationListFilterDto.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
40 changes: 40 additions & 0 deletions
40
...va/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyInvitationResponseDto.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
31 changes: 31 additions & 0 deletions
31
...com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestListFilterDto.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
41 changes: 41 additions & 0 deletions
41
...a/com/dreamteam/alter/adapter/inbound/general/workspace/dto/MyJoinRequestResponseDto.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.