Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
da4d76d
feat: 직원 초대 및 합류 요청 기능 구현
Mar 5, 2026
ba91f3e
feat: 합류요청 시 전화번호 검증 기준 추가
Mar 5, 2026
3ad4e54
feat: TOCTOU 경쟁 조건 취약점 수정
Mar 5, 2026
5b423b3
refactor: FCM 알림 발송을 트랜잭션 커밋 후 실행되도록 분리
Mar 5, 2026
a281d9c
refactor: 업장 초대시 전화번호 조회에 대한 N+1 문제 해결
Mar 5, 2026
44e9e68
refactor: Cursor Pagination 구성
Mar 8, 2026
cd89e0e
refactor: 요청/응답 DTO API 스팩 추가
Mar 8, 2026
764e9a4
refactor: 응답 DTO 기본 생성의 접근 제한자 PRIVATE으로 변경
Mar 8, 2026
9714dbf
SendWorkspaceInvitationRequestDto mager 패키지로 이동
Mar 8, 2026
e2e4f12
refactor: decline 메서드 isExpired 누락 수정
Mar 8, 2026
a85ba58
refactor: 초대 조회시 날짜와 상태 기준 필터링 조건 추가
Mar 8, 2026
3a32f03
refactor: 입력된 전화번호로 등록된 userIds를 먼저 추출 → IN 조건으로 범위를 좁혀 조회
Mar 8, 2026
ddf2635
refactor: private final 생성자 주입으로 변경
Mar 9, 2026
879f6e4
refactor: 동일번호 중복 방지를 위한 Set 타입으로 변경
Mar 9, 2026
e1259d8
refactor: 사용하지 않는 메서드 제거
Mar 9, 2026
c5ce62a
refactor: 목록 조회 UseCase에 필터 DTO 도입 및 totalCount 조회 개선
Mar 9, 2026
ed6f5b7
refactor: 사장님 초대 발송 로직 리팩터링
Mar 10, 2026
83409f4
refactor: Cursor Pagination 리팩터링
Mar 10, 2026
bbafa24
refactor: 파라미터 타입 일체화
Mar 10, 2026
4af93ac
refactor: 페이지네이션 리팩터링
Mar 10, 2026
2c7a431
test: 테스트케이스 작성
Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ src/main/generated/
/.claude/
CLAUDE.md
/.ai/
.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,13 @@ public static <T> ErrorResponse<T> of(ErrorCode errorCode, T data) {
);
}

public static <T> ErrorResponse<T> of(ErrorCode errorCode, String message, T data) {
return new ErrorResponse<>(
String.valueOf(LocalDateTime.now()),
errorCode.getCode(),
message,
data
);
}

}
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());
}
}
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
);
}
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;
}
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();
}
}
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;
}
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();
}
}
Loading
Loading