feat(chat): user block + placeholder messages#27
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
…27 review) 1. leaveRoom race condition 재도입 수정 - assertMembership + delete 패턴은 concurrent leave 시 두 요청이 모두 assert 를 통과해 SYSTEM 메시지를 이중 발사하는 race condition 유발 - PR #26 에서 수정한 delete 반환값 체크(deleted == 0 → 403)를 복구 2. sendMessage PERSONAL 방 차단 검증 추가 - createPersonalRoom 만 차단을 막으면 차단 전 이미 존재하던 방으로 계속 메시지 전송 가능한 반쪽짜리 차단 문제 수정 - PERSONAL 방 sendMessage 시 상대방과의 양방향 차단 여부를 검증하여 차단 상태면 CHAT_BLOCKED_BETWEEN_USERS(409) 반환 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the third piece of the chat membership story: members can leave
a room, GROUP rooms announce the departure via a SYSTEM message, and
rooms that lose their last member are soft-deleted so the list view
stays clean.
## Domain
- ChatMessage gains a `type` column (USER / SYSTEM). Existing rows
default to USER so the migration is non-disruptive.
- ChatMessage.SYSTEM_SENDER_ID = "SYSTEM" constant — senderId stays
NOT NULL, the new `type` column carries the actual classification.
- ChatRoomMemberRepository.deleteByChatRoomIdAndUserId for the leave
flow. Returns affected row count so callers can detect "already
not a member" without a separate exists check.
## Service
- ChatService.leaveRoom(roomId, userId)
- Membership-gated (non-members get CH003 — prevents bogus SYSTEM
messages from being broadcast on a leave-by-stranger request).
- GROUP: stores a "{nickname}님이 나갔습니다." SYSTEM message and
SSE-broadcasts it after commit (same phantom-message guard as
sendMessage).
- PERSONAL: silent leave — 1:1 partner is the only other party and
they already learn about the absence via the unread / message
history; no system noise.
- When the resulting member count hits zero, the room is
soft-deleted (isDeleted=true) regardless of type. List endpoints
already filter on isDeleted so the room disappears cleanly.
## API
- DELETE /api/chat/rooms/{roomId}/members/me — explicit "me" target
matches the semantics (only the caller can leave themselves).
- ChatMessageResponse exposes the new `type` field so clients can
render SYSTEM messages distinctly (centered grey banner vs. user
bubble). senderId of "SYSTEM" remains visible too for
backwards-compat clients that prefer to branch on it.
## Migration
docs/migrations/2026-05-18_chat_message_type.sql
- ADD COLUMN type with DEFAULT 'USER' (existing rows reclassified
retroactively).
- Adds CHECK constraint for ('USER','SYSTEM') so DB and the JPA enum
stay aligned (Hibernate ddl-auto=update doesn't refresh CHECK
constraints if one already exists).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The fourth and final pillar of the chat domain: a member can block
another user. Block is one-way (A → B); the blocker stops seeing the
blockee's PERSONAL messages and the two can no longer start a new
PERSONAL room while the block is in place.
- New ChatBlock entity (chat_blocks)
- UNIQUE (blocker_id, blocked_id) — one block row per ordered pair
- INDEX (blocker_id, created_at DESC) — "my block list"
- INDEX (blocked_id) — reverse lookup for two-way create-room guard
- ChatBlockRepository with helpers including existsBetween(a, b)
which folds the bidirectional check into a single call.
Per the design discussion:
| | PERSONAL | GROUP |
|---|---|---|
| Messages from blocked user | placeholder (after block.createdAt) | unchanged |
| Start new room with blocked user | 403 CH009 | (not applicable — group is spot-bound) |
| Unread count from blocked user | excluded (after block.createdAt) | included |
Importantly the block is only effective on messages whose createdAt is
strictly after the block row's createdAt — past history is preserved
so the blocker can still scroll back through pre-block conversation.
- ChatService now wires ChatBlockRepository.
- createPersonalRoom: rejects with CH009 when existsBetween(me, partner)
— covers both "I blocked them" and "they blocked me".
- getMessages: resolves a `Map<blockedId, blockedSince>` only for
PERSONAL rooms (Q5 — group messages are untouched) and uses
ChatMessageResponse.from(message, blocked=true) to swap content for
a placeholder while keeping id/senderId/createdAt for client layout.
- countUnreadByUserAndRoomIds now joins ChatRoom and applies the same
`room.type=PERSONAL AND block.createdAt < message.createdAt`
exclusion so the badge stays consistent with the visible state.
- New: getBlocks, blockUser (idempotent via DataIntegrityViolation
retry), unblockUser (idempotent no-op when nothing to remove).
- GET /api/chat/blocks
- POST /api/chat/blocks { "userId": "..." }
- DELETE /api/chat/blocks/{userId}
- ChatMessageResponse gains a `blocked` flag — when true, `content`
is the placeholder string; everything else mirrors the original
message so the client can still anchor it in time.
docs/migrations/2026-05-18_chat_block.sql
- CREATE TABLE chat_blocks + two indices
- CH009 CHAT_BLOCKED_BETWEEN_USERS (403)
- CH010 CHAT_BLOCK_TARGET_NOT_FOUND (404)
- CH011 CHAT_SELF_BLOCK_NOT_ALLOWED (400)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…27 review) 1. leaveRoom race condition 재도입 수정 - assertMembership + delete 패턴은 concurrent leave 시 두 요청이 모두 assert 를 통과해 SYSTEM 메시지를 이중 발사하는 race condition 유발 - PR #26 에서 수정한 delete 반환값 체크(deleted == 0 → 403)를 복구 2. sendMessage PERSONAL 방 차단 검증 추가 - createPersonalRoom 만 차단을 막으면 차단 전 이미 존재하던 방으로 계속 메시지 전송 가능한 반쪽짜리 차단 문제 수정 - PERSONAL 방 sendMessage 시 상대방과의 양방향 차단 여부를 검증하여 차단 상태면 CHAT_BLOCKED_BETWEEN_USERS(409) 반환 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
FRONTEND.md 검토 결과 발견한 Chat 도메인 불일치 항목 수정.
1. Base URL: /api/chat → /api/v1/chat (FRONTEND.md Base URL 계약)
2. SSE 경로: GET /connect?roomId= → GET /rooms/{roomId}/stream
- path variable 방식으로 변경 (FRONTEND.md 계약)
- 인증 + 멤버십 검증 추가 (assertMembershipPublic)
3. ChatMessageResponse 필드명 수정 (FRONTEND.md ChatMessage 타입)
- type: ChatMessageType → kind: String ("message" | "system")
- senderId → authorId (SYSTEM 메시지는 null)
- authorName 필드 추가 (발신자 닉네임)
4. 메시지 목록 응답 구조 수정 (FRONTEND.md GET /chat/rooms/{roomId}/messages)
- 기존: ApiResponse<{ messages, nextCursor: Long, hasMore }>
- 변경: ApiResponse<List<ChatMessageResponse>, { nextCursor: String, hasNext }>
- ApiResponse에 meta 필드 추가 (success(data, meta) 오버로드)
- hasMore → hasNext, nextCursor Long → String
5. getMessages 서비스: 발신자 닉네임 배치 조회 추가 (authorName 제공)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6a71915 to
8523037
Compare
* docs(capstone): codify PR workflow — full CodeRabbit review + dual reviewer policy Adds a new section "PR 워크플로우" to capstone.md so any AI worker (or human) opening a PR follows the same pre-merge checklist: - Always trigger @coderabbitai full review on PR creation, and report rate-limit timing back to the user when blocked. - Always assign both ca5tlechan and hoTan35 as reviewers — one approval unblocks merge. - Re-trigger after fix pushes, since CodeRabbit may have evaluated the pre-fix snapshot. This makes the policy that we've been applying ad-hoc on PR #21..#27 explicit and discoverable for new contributors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(capstone): make reviewer policy relative to current user Original wording hardcoded "ca5tlechan, hoTan35" as the two reviewers, which only made sense from 김동현's seat. When ca5tlechan or hoTan35 themselves open PRs they would end up assigning themselves as their own reviewer. Re-frame the rule as "the two workers other than yourself" and add an explicit name ↔ GitHub login mapping table so the AI worker can derive the correct --reviewer arguments from General Rules #1 (current user identification) without asking the user every time. Examples now in the doc: - 김동현 -> --reviewer "hoTan35,ca5tlechan" - 이성찬 -> --reviewer "hoTan35,ThonApple" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
채팅 4부작 마지막. PR A 멤버십 / PR B 읽음 / PR C 나가기 위에 차단 을 얹어 카카오톡식 채팅 UX 핵심을 완성.
적용 시점: 차단 row 의 `createdAt` 이후 메시지에만 적용 (소급 X — 과거 대화는 그대로 보임).
Data model
API
`ChatMessageResponse` 에 `blocked` 플래그 추가. true 일 때 `content` 는 placeholder, 나머지 필드 (id/senderId/createdAt 등) 는 원본 유지 → 클라가 시간 위치/발신자를 기준으로 메시지를 렌더링 가능.
Service
Migration
`docs/migrations/2026-05-18_chat_block.sql`
Error codes
결정 사항 (설계 단계 합의)
Test plan
🤖 Generated with Claude Code