Skip to content

feat(chat): user block + placeholder messages#27

Merged
ThonApple merged 4 commits into
developfrom
feature/chat-block
May 18, 2026
Merged

feat(chat): user block + placeholder messages#27
ThonApple merged 4 commits into
developfrom
feature/chat-block

Conversation

@ThonApple
Copy link
Copy Markdown
Collaborator

⚠️ Stacked on top of PR #26 (chat leave). Base 는 `feature/chat-leave`.
PR #26 머지되면 base 가 자동으로 develop 으로 redirect 됨.

Summary

채팅 4부작 마지막. PR A 멤버십 / PR B 읽음 / PR C 나가기 위에 차단 을 얹어 카카오톡식 채팅 UX 핵심을 완성.

PERSONAL GROUP
차단된 유저의 메시지 placeholder ("차단한 사용자의 메시지입니다.") 그대로 노출
새 1:1 방 시작 403 CH009 (양방향 차단) (해당 없음)
unreadCount 차단된 유저 메시지 제외 포함

적용 시점: 차단 row 의 `createdAt` 이후 메시지에만 적용 (소급 X — 과거 대화는 그대로 보임).

Data model

  • 신규 `ChatBlock` 엔티티 (`chat_blocks`)
    • UNIQUE `(blocker_id, blocked_id)` — 단방향 pair
    • INDEX `(blocker_id, created_at DESC)` — 내 차단 목록 조회
    • INDEX `(blocked_id)` — 양방향 검증용 역조회

API

Method Path 설명
GET `/api/chat/blocks` 내 차단 목록 (닉네임 포함)
POST `/api/chat/blocks` 차단 (멱등 — 이미 차단 시 기존 row 반환)
DELETE `/api/chat/blocks/{userId}` 차단 해제 (멱등 — no-op 시 200)

`ChatMessageResponse` 에 `blocked` 플래그 추가. true 일 때 `content` 는 placeholder, 나머지 필드 (id/senderId/createdAt 등) 는 원본 유지 → 클라가 시간 위치/발신자를 기준으로 메시지를 렌더링 가능.

Service

  • `createPersonalRoom`: `existsBetween(me, partner)` 으로 양방향 차단 검증 — 한쪽이라도 차단 중이면 CH009
  • `getMessages`: PERSONAL 방에서만 `Map<blockedId, blockedSince>` 조회, `message.createdAt > blockedSince` 인 메시지를 placeholder 로 변환
  • `countUnreadByUserAndRoomIds`: ChatRoom join + `r.type=PERSONAL AND block.createdAt < m.createdAt` 제외 조건 추가
  • `blockUser`: 멱등 — DataIntegrityViolationException race 흡수 후 재조회
  • `unblockUser`: 멱등 no-op

Migration

`docs/migrations/2026-05-18_chat_block.sql`

  • `CREATE TABLE chat_blocks` + 2 indices

Error codes

  • CH009 `CHAT_BLOCKED_BETWEEN_USERS` (403)
  • CH010 `CHAT_BLOCK_TARGET_NOT_FOUND` (404)
  • CH011 `CHAT_SELF_BLOCK_NOT_ALLOWED` (400)

결정 사항 (설계 단계 합의)

  • Q1: 차단 필터는 DTO 단 적용 → cursor/페이지네이션 무결성 보존
  • Q2: 차단 시점 이후 메시지만 가림 (과거 대화 보존)
  • Q3: 메시지는 placeholder 로 대체 (제거가 아니라 가림)
  • Q4: unread 카운트에서도 동일 적용
  • Q5: PERSONAL 만 차단 — GROUP 은 다수 참여자 공간이라 무시
  • Q6: 차단/해제 모두 멱등
  • Q7: 자기 차단 금지 (CH011)
  • Q8: SSE 실시간 차단 반영은 본 PR 범위 밖

Test plan

  • 로컬 컴파일
  • (배포 후) A → B 차단 → A 가 PERSONAL 방 열면 B 의 새 메시지는 placeholder
  • (배포 후) A → B 차단 → 차단 이전 메시지는 그대로 보임
  • (배포 후) A → B 차단 → A 가 B 와 새 PERSONAL 방 시도 → 403 CH009
  • (배포 후) A → B 차단 → B 가 A 와 새 PERSONAL 방 시도 → 403 CH009 (양방향)
  • (배포 후) GROUP 방의 차단된 멤버 메시지는 그대로 노출 (Q5)
  • (배포 후) 차단된 유저 메시지가 unreadCount 에 안 잡힘
  • (배포 후) 자기 자신 차단 → 400 CH011
  • (배포 후) 차단 한 적 없는 유저 unblock → 200 (멱등)

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31a4e3f5-6c29-41e9-bddf-c09ff4f38aaa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/chat-block

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ThonApple ThonApple requested review from ca5tlechan and hoTan35 May 18, 2026 05:38
@ThonApple
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Full review triggered.

@ThonApple
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Full review triggered.

@ThonApple
Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Full review triggered.

ThonApple added a commit that referenced this pull request May 18, 2026
…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>
ThonApple and others added 4 commits May 18, 2026 17:23
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>
@ThonApple ThonApple force-pushed the feature/chat-block branch from 6a71915 to 8523037 Compare May 18, 2026 08:24
@ThonApple ThonApple changed the base branch from feature/chat-leave to develop May 18, 2026 08:24
@ThonApple ThonApple merged commit de1a75c into develop May 18, 2026
1 check passed
ThonApple added a commit that referenced this pull request May 18, 2026
* 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>
@ThonApple ThonApple deleted the feature/chat-block branch May 18, 2026 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants