Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
174 changes: 174 additions & 0 deletions docs/spec/user-profile-bottom-sheet-api-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# 유저 프로필 바텀시트 API 명세 — 다른 유저 프로필 탐색

> 기반: 채팅 화면 > 닉네임 탭 > 유저 프로필 바텀시트 (페이지명: 유저 프로필 바텀시트)
> 버전: 1.0
> 대상: 회원 (비회원 진입 불가)

## 1. 요구사항 핵심 요약

### 1-1. 프로필 바텀시트 진입
- 채팅 메시지의 **프로필 이미지** 또는 **닉네임** 탭 시 노출
- **비회원**은 진입 불가 (FE에서 차단, API도 인증 필수)
- 진입 가능 화면: 전체 채팅 / 몰입형 투표 바텀시트형 채팅 / 일반형 투표 바텀시트형 채팅 (동일 바텀시트)

### 1-2. 참여투표 영역
- 상단: 참여투표 **총 개수** 표시 (예: `참여투표 24`)
- 카드 최대 **3개** 노출 (더보기 없음)
- 정렬: **최신 활동순** (투표 생성 시각 ❌ → 해당 유저의 참여/활동 시각 ⭕)
- 카드 구성:
- 투표 상태 라벨: `ONGOING`(진행중) / `ENDED`(종료)
- 투표 제목 (1줄 말줄임 — FE 처리)
- **프로필 대상 유저**가 선택한 선택지 텍스트 (체크 아이콘 + 1줄 말줄임 — FE 처리)
- 우측 화살표 `>` (FE)
- 1~2개만 있으면 있는 만큼만 노출 (빈 슬롯 없음)

### 1-3. 투표 리스트 카드 탭 — 화면 랜딩 (FE)
| 투표 상태 | 투표 타입 | 조회자(본인) 참여 | 랜딩 화면 |
|-----------|-----------|-------------------|-----------|
| 진행중 | 일반형 | - | 일반형 투표_회원 |
| 진행중 | 몰입형 | - | 몰입형 투표_회원 |
| 종료 | - | 참여 O | 투표결과_회원_참여O |
| 종료 | - | 참여 X | 투표결과_회원_참여X |

- 삭제된 투표 탭 시: `삭제된 투표입니다` Error 토스트 (기존 `GET /api/votes/{voteId}` 404 활용)

### 1-4. FE 전달사항 (서버 비관여)
- 바텀시트 고정 높이 500px
- Dim Layer: `#000000` 40%
- 다크 모드 외곽선 Stroke
- 투표 리스트 전체 Frame 터치 영역

---

## 2. 최신 활동순 정렬 기준 (서버)

프로필 **대상 유저**(`targetUserId`) 기준, 투표별 마지막 활동 시각:

```
lastActivityAt = GREATEST(
vote_participation.updated_at,
COALESCE(
(SELECT MAX(chat_message.created_at)
FROM chat_message
WHERE sender_id = targetUserId AND vote_id = participation.vote_id),
vote_participation.created_at
)
)
```

- 참여만 하고 채팅이 없으면 `participation.created_at` 사용
- 선택지 변경 시 `participation.updated_at` 반영
- 채팅 메시지 전송 시 해당 시각이 더 최신이면 채팅 시각 우선

---

## 3. REST API

### GET `/api/users/{userId}/profile-sheet` — 다른 유저 프로필 바텀시트 조회

**인증**: 필수 (회원 JWT). 비회원 401.

**Path Parameter**

| 파라미터 | 타입 | 설명 |
|----------|------|------|
| `userId` | `Long` | 프로필 조회 대상 유저 ID |

**Response 200**

```json
{
"userId": 42,
"nickname": "승부사",
"imageColor": "GREEN",
"participatedVoteCount": 24,
"recentParticipatedVotes": [
{
"voteId": 101,
"title": "직장인 점심시간 혼밥 vs 같이 먹기",
"status": "ONGOING",
"selectedOptionLabel": "혼밥이 편하다",
"viewerParticipated": true
},
{
"voteId": 88,
"title": "주말엔 집콕 vs 나들이",
"status": "ENDED",
"selectedOptionLabel": "나들이가 좋다",
"viewerParticipated": false
}
]
}
```

**필드 설명**

| 필드 | 타입 | 설명 |
|------|------|------|
| `userId` | `Long` | 대상 유저 ID |
| `nickname` | `String` | 닉네임. 탈퇴 유저는 `알 수 없음` |
| `imageColor` | `ImageColor \| null` | 프로필 아이콘 색상. 탈퇴 유저는 `null` |
| `participatedVoteCount` | `long` | 참여투표 총 개수 |
| `recentParticipatedVotes` | `Array` | 최신 활동순 상위 3개 (0~3개) |
| `recentParticipatedVotes[].voteId` | `Long` | 투표 ID (카드 탭 시 랜딩용) |
| `recentParticipatedVotes[].title` | `String` | 투표 제목 |
| `recentParticipatedVotes[].status` | `ONGOING \| ENDED` | 투표 상태 |
| `recentParticipatedVotes[].selectedOptionLabel` | `String` | **대상 유저** 선택지 텍스트 |
| `recentParticipatedVotes[].viewerParticipated` | `boolean` | **조회자(본인)** 참여 여부 (종료 투표 랜딩 분기용) |

**에러**

| HTTP | code | 상황 |
|------|------|------|
| 401 | `UNAUTHORIZED` | 비회원 |
| 404 | `E400000` | 존재하지 않는 `userId` |

---

## 4. 구현 구조

```
user/
├── adapter/web/
│ ├── UserController.java # GET /api/users/{userId}/profile-sheet
│ └── dto/
│ └── UserProfileBottomSheetResponse.java
├── port/
│ ├── UserProfileQueryService.java
│ └── in/
│ └── UserProfileQueryUseCase.java
vote/
└── domain/
└── VoteParticipationRepository.java # findTopVoteIdsByRecentActivity
```

**서비스 흐름**
1. `targetUserId` 유저 존재 확인 (`UserQueryUseCase.getUser`)
2. `countByUserId(targetUserId)` → `participatedVoteCount`
3. `findTop3VoteIdsByRecentActivity(targetUserId)` → voteId 목록
4. Vote / VoteOption / Participation 일괄 조회 후 DTO 조립
5. 각 vote에 대해 `existsByVoteIdAndUserId(voteId, viewerUserId)` → `viewerParticipated`

---

## 5. FE 연동 가이드

### 바텀시트 오픈
```
GET /api/users/{senderId}/profile-sheet
Authorization: Bearer {accessToken}
```
- 채팅 메시지의 `senderId`를 path `userId`로 전달

### 카드 탭 랜딩 분기 (클라이언트)
```typescript
function resolveLanding(vote: RecentParticipatedVote, entryScreen: VoteEntryScreen) {
if (vote.status === 'ONGOING') {
return entryScreen; // 프로필 진입 전 머무르던 투표 화면으로 복귀
}
return vote.viewerParticipated ? 'RESULT_MEMBER_JOINED' : 'RESULT_MEMBER_NOT_JOINED';
}
```

### 삭제된 투표
- 카드 탭 후 `GET /api/votes/{voteId}` 또는 결과 API 호출 시 404 → 토스트 `삭제된 투표입니다`
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.ject.vs.user.adapter.web;

import com.ject.vs.chat.domain.ChatMessage;
import com.ject.vs.chat.domain.ChatMessageRepository;
import com.ject.vs.support.ChatIntegrationTestSupport;
import com.ject.vs.user.domain.User;
import com.ject.vs.vote.domain.Vote;
import com.ject.vs.vote.domain.VoteOption;
import com.ject.vs.vote.domain.VoteParticipation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;

import java.time.Duration;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@AutoConfigureMockMvc
@DisplayName("UserProfileBottomSheet API 통합 테스트")
class UserProfileBottomSheetIntegrationTest extends ChatIntegrationTestSupport {

@Autowired
private MockMvc mockMvc;

@Autowired
private ChatMessageRepository chatMessageRepository;

@Nested
@DisplayName("GET /api/users/{userId}/profile-sheet")
class GetProfileBottomSheet {

@Test
@DisplayName("참여투표 개수와 최신 활동순 3개 카드를 반환한다")
void returns_profile_sheet_with_recent_votes() throws Exception {
User targetUser = createUserWithNickname("프로필대상");
User viewer = createUserWithNickname("조회자");

VoteFixture vote1 = createVoteWithOptions("투표1", "선택1-A", "선택1-B", null);
VoteFixture vote2 = createImmersiveVoteWithOptions("투표2", "선택2-A", "선택2-B");
VoteFixture vote3 = createVoteWithOptions("투표3", "선택3-A", "선택3-B", null);
createVoteWithOptions("투표4", "선택4-A", "선택4-B", null);

participate(targetUser, vote1, vote1.optionA());
participate(targetUser, vote2, vote2.optionB());
participate(targetUser, vote3, vote3.optionA());
participate(viewer, vote1, vote1.optionA());

// vote3에 채팅 활동을 추가해 최신순 1위로 올린다
chatMessageRepository.save(ChatMessage.of(vote3.vote().getId(), targetUser, "최근 채팅"));
entityManager.flush();

mockMvc.perform(get("/api/users/{userId}/profile-sheet", targetUser.getId())
.with(asUser(viewer.getId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(targetUser.getId()))
.andExpect(jsonPath("$.nickname").value("프로필대상"))
.andExpect(jsonPath("$.imageColor").value("GREEN"))
.andExpect(jsonPath("$.participatedVoteCount").value(3))
.andExpect(jsonPath("$.recentParticipatedVotes.length()").value(3))
.andExpect(jsonPath("$.recentParticipatedVotes[0].voteId").value(vote3.vote().getId()))
.andExpect(jsonPath("$.recentParticipatedVotes[0].title").value("투표3"))
.andExpect(jsonPath("$.recentParticipatedVotes[0].status").value("ONGOING"))
.andExpect(jsonPath("$.recentParticipatedVotes[0].selectedOptionLabel").value("선택3-A"))
.andExpect(jsonPath("$.recentParticipatedVotes[0].viewerParticipated").value(false))
.andExpect(jsonPath("$.recentParticipatedVotes[1].voteId").exists())
.andExpect(jsonPath("$.recentParticipatedVotes[2].voteId").exists());
}

@Test
@DisplayName("비회원은 401을 반환한다")
void returns_401_for_anonymous() throws Exception {
User targetUser = createUserWithNickname("대상");

mockMvc.perform(get("/api/users/{userId}/profile-sheet", targetUser.getId()))
.andExpect(status().isUnauthorized());
}

@Test
@DisplayName("존재하지 않는 유저는 404를 반환한다")
void returns_404_when_user_not_found() throws Exception {
User viewer = createUserWithNickname("조회자");

mockMvc.perform(get("/api/users/{userId}/profile-sheet", 999_999L)
.with(asUser(viewer.getId())))
.andExpect(status().isNotFound());
}
}

private VoteFixture createVoteWithOptions(String title, String optionALabel, String optionBLabel, String imageUrl) {
Vote vote = voteRepository.save(Vote.create(
title, "content", "https://example.com/thumb.jpg",
imageUrl, Duration.ofHours(24), clock));
VoteOption optionA = voteOptionRepository.save(VoteOption.of(vote, optionALabel, 0));
VoteOption optionB = voteOptionRepository.save(VoteOption.of(vote, optionBLabel, 1));
entityManager.flush();
entityManager.clear();
Vote reloaded = voteRepository.findById(vote.getId()).orElseThrow();
return new VoteFixture(reloaded, optionA, optionB);
}

private VoteFixture createImmersiveVoteWithOptions(String title, String optionALabel, String optionBLabel) {
return createVoteWithOptions(title, optionALabel, optionBLabel, "https://example.com/immersive.jpg");
}

private void participate(User user, VoteFixture fixture, VoteOption option) {
voteParticipationRepository.save(
VoteParticipation.ofMember(fixture.vote().getId(), user.getId(), option.getId()));
}

private record VoteFixture(Vote vote, VoteOption optionA, VoteOption optionB) {}
}
17 changes: 17 additions & 0 deletions src/main/java/com/ject/vs/user/adapter/web/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.ject.vs.config.CookieProperties;
import com.ject.vs.user.adapter.web.dto.*;
import com.ject.vs.user.port.UserProfileQueryService;
import com.ject.vs.user.port.UserService;
import com.ject.vs.vote.exception.UnauthorizedException;
import com.ject.vs.util.CookieUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -23,6 +25,7 @@
@Slf4j
public class UserController {
private final UserService userService;
private final UserProfileQueryService userProfileQueryService;
private final CookieProperties cookieProperties;

@Operation(summary = "추가 정보 설정", description = "사용자 추가 정보(닉네임, 성별, 생년월일)를 설정합니다.")
Expand Down Expand Up @@ -62,6 +65,20 @@ public ResponseEntity<UserProfileResponse> getMyProfile(@AuthenticationPrincipal
return ResponseEntity.ok(response);
}

@Operation(
summary = "다른 유저 프로필 바텀시트 조회",
description = "채팅 화면에서 닉네임/프로필 탭 시 노출되는 다른 유저 프로필 정보를 조회합니다. 회원 전용입니다."
)
@GetMapping("/{userId}/profile-sheet")
public ResponseEntity<UserProfileBottomSheetResponse> getProfileBottomSheet(
@AuthenticationPrincipal Long viewerUserId,
@PathVariable Long userId) {
if (viewerUserId == null) {
throw new UnauthorizedException();
}
return ResponseEntity.ok(userProfileQueryService.getProfileBottomSheet(userId, viewerUserId));
}

@Operation(summary = "기본 프로필 초기화", description = "사용자 기본 프로필 정보를 초기화합니다.")
@PostMapping("/info")
public ResponseEntity<UserProfileDefaultResponse> initializeDefaultProfile(@AuthenticationPrincipal Long userId, @RequestBody UserProfileRequest userInfo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.ject.vs.user.adapter.web.dto;

import com.ject.vs.user.domain.ImageColor;
import com.ject.vs.vote.domain.VoteStatus;
import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

@Schema(description = "다른 유저 프로필 바텀시트 응답")
public record UserProfileBottomSheetResponse(
@Schema(description = "프로필 대상 유저 ID", example = "42")
Long userId,

@Schema(description = "닉네임", example = "승부사")
String nickname,

@Schema(description = "프로필 아이콘 색상", example = "GREEN", nullable = true)
ImageColor imageColor,

@Schema(description = "참여투표 총 개수", example = "24")
long participatedVoteCount,

@Schema(description = "최신 활동순 참여투표 (최대 3개)")
List<ParticipatedVoteCard> recentParticipatedVotes
) {
@Schema(description = "참여투표 카드")
public record ParticipatedVoteCard(
@Schema(description = "투표 ID", example = "101")
Long voteId,

@Schema(description = "투표 제목", example = "직장인 점심시간 혼밥 vs 같이 먹기")
String title,

@Schema(description = "투표 상태", example = "ONGOING")
VoteStatus status,

@Schema(description = "프로필 대상 유저가 선택한 선택지 텍스트", example = "혼밥이 편하다")
String selectedOptionLabel,

@Schema(description = "조회자(본인)의 해당 투표 참여 여부", example = "true")
boolean viewerParticipated
) {}
}
Loading
Loading