feat(conversation): 구매자 문의 채팅 기반 — 진입 컨텍스트·메시지 전송·FAQ 자동응답·인사말 - #268
Conversation
figma 알림센터(문의 채팅) 화면 대응 2/4. 대화 모델·판매자 API만 있던
conversation feature에 구매자 측 진입·전송 경로를 신설한다.
변경점
- Prisma: store.greeting_message VARCHAR(500) 추가(마이그레이션 동반) —
문의 채팅 인사말 템플릿({nickname}/{storeName} 치환, null이면 기본 문구)
- Query storeInquiryContext(storeId): 매장 프로필·요일별 상담시간(영업시간
rows, "HH:mm")·치환 완료 인사말·질문 칩(활성 StoreFaqTopic)·기존 대화 ID
- Mutation sendConversationMessage: 텍스트 전송. 첫 전송 시 대화 생성 +
치환 완료 인사말을 STORE 메시지로 선저장(대화당 1회, 이력 보존)
- Mutation sendConversationFaqMessage: 칩 탭 → 유저 질문(TEXT) + 매장
자동응답(FAQ answer_html, HTML) 트랜잭션 저장. 답변은 저장 시점 스냅샷
- 대화 upsert는 (account_id, store_id) 유니크와 동일 범위(삭제 포함)로
조회해 P2002를 예방하고, 동시 첫 전송 레이스는 P2002 복구로 방어
- 판매자: sellerUpdateStoreBasicInfo에 greetingMessage 확장(빈 문자열 →
null 저장 = 기본 문구 복귀) + SellerStore에 노출. 별도 mutation 대신
기존 basic info 확장(자체 판단 — API 표면 최소화)
- 활성 USER 판정은 user feature의 evaluateActiveUserAccount 정책 공유
- 시드: FAQ 칩 5종 + 대화 2건(칩 문답·판매자 답장 3건 안읽음 재현),
resetSeedScope에 대화·FAQ 정리 범위 추가
회귀 테스트
- service spec 12케이스(기본/커스텀 인사말 치환, FAQ 정렬·비활성 제외,
첫 전송 인사말 선저장, 중복 대화 방지, soft-delete 대화 재사용,
본문 검증, 비활성 매장·FAQ 거절, 권한 3종)
- 매퍼 helper 순수 단위 6케이스, resolver 통합 2케이스, input spec 6케이스,
seller greeting 설정/초기화 케이스
🧹 knip — dead-code 리포트전체 리포트
|
🩺 NestJS Doctor — 90/100 (Excellent)진단 294건 (error 0).
architecture / security 상위 항목
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Coverage report
Test suite run success1826 tests passing in 215 suites. Report generated by 🧪jest coverage report action from 0224c0d |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 46a2543920
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| where: { | ||
| account_id: args.accountId, | ||
| store_id: args.storeId, | ||
| deleted_at: undefined, |
There was a problem hiding this comment.
Reactivate a reused soft-deleted conversation
When this lookup finds a soft-deleted conversation, subsequent writes append messages without clearing deleted_at. The repository's normal buyer and seller reads are filtered by the soft-delete extension, so storeInquiryContext continues returning a null conversation ID and the seller cannot discover or read the newly sent messages even though the mutation reports success. Restore the conversation when reusing it, or permit creation of a new active row.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 897a8a8 — 메시지 저장 트랜잭션의 대화 갱신에 deleted_at: null 명시로 재사용 시 복구(평상시 no-op). 복구 검증 spec 추가.
| conversation = await tx.storeConversation.findFirstOrThrow({ | ||
| where: { | ||
| account_id: args.accountId, | ||
| store_id: args.storeId, | ||
| deleted_at: undefined, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Retry the concurrent-create lookup outside the old snapshot
With this repository's MySQL provider and its default repeatable-read isolation, two simultaneous first messages can both establish a snapshot in the preceding findFirst; after the losing insert waits and raises P2002, this findFirstOrThrow still runs in that same snapshot and may not see the winner's newly committed row. The purported race recovery therefore still rejects one sender; retry the operation in a new transaction or use a locking/upsert strategy that does not reread through the stale snapshot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 897a8a8 — 대화 확보를 메시지 트랜잭션 밖 getOrCreateConversation으로 분리. P2002 복구 재조회가 새 문장(새 스냅샷)에서 실행돼 승자 row를 본다. 생성 후 메시지 tx 실패 시 남는 빈 대화는 목록에서 제외됨(last_message_at null).
| """문의 채팅 인사말 템플릿. 빈 문자열이면 기본 문구로 되돌린다(null 저장).""" | ||
| greetingMessage: String |
There was a problem hiding this comment.
Expose greetingMessage on SellerStore
This adds greetingMessage only to the update input, while the SellerStore GraphQL type still lacks the corresponding field. Consequently sellers can update the template but cannot request its current value from either the mutation result or sellerMyStore, despite the service mapper returning it; add the field to SellerStore as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 897a8a8 — SellerStore 타입에 greetingMessage 추가. 편집 스크립트 오류로 type 블록 반영이 유실됐던 것(입력·매퍼·출력 타입은 반영돼 있었음).
Codex 지적 3건 반영. - P1: soft-delete된 대화 재사용 시 deleted_at을 복구한다 — 삭제 상태로 메시지만 쌓이면 구매자·판매자 어느 조회에도 잡히지 않아 유실돼 보인다. 메시지 저장 트랜잭션의 대화 갱신에서 deleted_at: null 명시(평상시 no-op) - P2: 대화 확보(getOrCreateConversation)를 메시지 트랜잭션 밖으로 분리 — REPEATABLE READ 스냅샷 안의 P2002 복구 재조회는 경쟁 트랜잭션의 커밋 row를 못 볼 수 있다. 새 문장(새 스냅샷)에서 재조회해 승자 row를 얻는다. 대화 생성 후 메시지 트랜잭션이 실패해도 빈 대화는 목록에 노출되지 않음 - P2: SellerStore GraphQL 타입에 greetingMessage 필드 누락 보완(설정값 조회 불가 문제). 입력·매퍼·출력 타입은 기존 커밋에 이미 반영돼 있었음 - 재사용 spec에 deleted_at 복구 검증 추가
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 897a8a8a40
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 않을 수 있다(리뷰 반영). 메시지 삽입 전에 대화 row가 확정되면 충분하고, | ||
| // 이후 메시지 트랜잭션이 실패해도 빈 대화 row는 목록에 노출되지 않는다 | ||
| // (last_message_at null). | ||
| const { conversation, created } = await this.getOrCreateConversation(args); |
There was a problem hiding this comment.
Serialize first-message initialization
When two first-message requests for the same buyer and store overlap, the conversation creation here commits before the message transaction begins. The second request can therefore observe the new row as created: false and insert its user message before the first request inserts the greeting, violating the API contract that the greeting is stored first; if the first message transaction fails, a retry also treats the empty conversation as initialized and never stores a greeting. Keep initialization atomic or explicitly track/lock whether the greeting has been persisted.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 메시지 트랜잭션 안에서 대화 row SELECT ... FOR UPDATE 잠금 후 메시지 수 0건 기준으로 인사말 삽입. 동시 첫 전송은 잠금으로 직렬화되고, 생성 후 실패 재시도도 count 기준이라 인사말 계약(항상 첫 메시지)이 유지됨. spec 갱신.
Codex 지적 반영: "대화를 새로 생성했는가" 플래그 기준 인사말 저장은 동시 첫 전송(후발 요청이 인사말보다 먼저 유저 메시지 삽입)과 생성 후 메시지 트랜잭션 실패 재시도(빈 대화를 초기화 완료로 오인 → 인사말 영구 누락)에서 "인사말이 항상 첫 메시지" 계약을 깨뜨린다. - 메시지 트랜잭션 안에서 대화 row를 SELECT ... FOR UPDATE로 잠근 뒤 실제 메시지 수 0건일 때만 인사말을 삽입 — 동시 전송은 잠금으로 직렬화, 실패 재시도도 count 기준이라 인사말이 복구된다 - 빈 대화(soft-delete 재사용 포함)에는 인사말부터 저장되도록 spec 갱신
|
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: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 590ba92e97
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 이후 메시지 트랜잭션이 실패해도 빈 대화 row는 목록에 노출되지 않는다 | ||
| // (last_message_at null). | ||
| const { conversation } = await this.getOrCreateConversation(args); |
There was a problem hiding this comment.
Keep uninitialized conversations hidden from sellers
When a first send creates the conversation here, the row commits before the message transaction starts. Fresh evidence after the earlier initialization fix is that listConversationsByStore filters only by store/cursor and does not implement the claimed last_message_at exclusion. A seller polling during this interval can therefore obtain the empty conversation and reply first; the buyer transaction then counts that reply and permanently skips the greeting. A failed message transaction likewise leaves a phantom conversation visible indefinitely, so initialization should remain hidden/atomic until its first messages are committed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 대화 생성을 메시지 트랜잭션 안으로 되돌려 원자화(lockOrCreateConversation). 스냅샷 문제는 P2002 복구를 FOR UPDATE 잠금 조회(locking read — MVCC 우회, 최신 커밋 읽음)로 수행해 해소. 실패 시 전체 롤백이라 빈 대화가 커밋되는 구간 자체가 없어짐.
Codex 지적 반영: 대화 row가 메시지 트랜잭션보다 먼저 커밋되면 sellerConversations(빈 대화 미필터)에 노출돼 판매자가 인사말보다 먼저 답장할 수 있고, 메시지 저장 실패 시 유령 대화가 영구히 남는다. - 대화 생성/잠금을 메시지 트랜잭션 안 lockOrCreateConversation으로 통합: 기존 대화는 id FOR UPDATE 잠금, 부재 시 본 트랜잭션에서 생성 - 동시 첫 전송의 P2002 복구는 FOR UPDATE 잠금 조회(locking read)로 수행 — REPEATABLE READ 스냅샷을 우회해 승자 커밋 row를 읽으므로, 앞서 지적된 스냅샷 문제 없이 원자성을 되찾는다 - 실패 시 전체 롤백이라 유령 대화·인사말 누락 재시도 문제 모두 해소
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbadab58fb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return rows[0]?.id ?? null; | ||
| }; | ||
|
|
||
| const existing = await this.prisma.storeConversation.findFirst({ |
There was a problem hiding this comment.
Move the preflight read outside the open transaction
When concurrent buyer sends occupy every Prisma pool connection, each interactive transaction holds one connection here while this root-client query waits to borrow a second one, so all requests can remain mutually blocked until the pool or transaction timeout expires; this also fails deterministically with connection_limit=1. Perform this preflight before opening $transaction, or execute the existence check through tx while preserving the locking-read recovery strategy.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
반영: 사전 조회를 tx 경유로 변경(커넥션 1개 유지). 스냅샷이 경쟁 커밋을 못 보는 경우는 create → P2002 → FOR UPDATE 잠금 조회가 복구.
Codex 지적 반영: $transaction 콜백 안에서 루트 클라이언트 조회는 풀 커넥션을 추가로 점유해, 동시 전송이 풀을 소진하면 상호 대기(타임아웃)가 난다(connection_limit=1이면 즉시 재현). 사전 조회를 tx 경유로 변경 — tx 스냅샷이 경쟁 커밋을 못 봐도 create → P2002 → FOR UPDATE 잠금 조회 경로가 복구하므로 의미는 동일하다.
배경
figma 알림센터(문의 채팅) 화면 대응 2/4. conversation feature에는 DB 모델과 판매자 측 API만 있었고, 구매자 측(대화 생성·전송)은 전무했다.
변경점
store.greeting_message VARCHAR(500)추가(마이그레이션) — 인사말 템플릿({nickname}/{storeName}치환, null이면 서버 기본 문구).storeInquiryContext(storeId): 채팅 진입 화면 데이터 — 매장 프로필, 요일별 상담시간("HH:mm"), 치환 완료 인사말, 질문 칩(활성 StoreFaqTopic 정렬순), 기존 대화 ID.sendConversationMessage: 텍스트 전송. 첫 전송 시 대화 생성 + 인사말 STORE 메시지 선저장(대화당 1회, 이력 보존 — 판매자 화면에도 보임).sendConversationFaqMessage: 칩 탭 → 유저 질문(TEXT) + 자동응답(FAQ answer_html, HTML) 트랜잭션 저장. 답변은 저장 시점 스냅샷.sellerUpdateStoreBasicInfo에greetingMessage확장(빈 문자열 → null = 기본 문구 복귀),SellerStore에 노출.resetSeedScope정리 범위 갱신(재시드 멱등 확인 완료).테스트
yarn validate전체 통과 (215 suites / 1825 tests), 시드 2회 연속 실행 멱등 확인