Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b435de9
feat(user): 알림센터 알림 탭 대응 — event·연관 정보 노출, 3개월 필터, 커서 전환
chanwoo7 Sep 1, 2026
a48c907
fix(user): 주문 연결 알림 상품명은 스냅샷 우선 (PR #267 리뷰 반영)
chanwoo7 Sep 1, 2026
05bcf01
fix(user): Date 범위 밖 커서 timestamp 거부 (PR #267 리뷰 반영)
chanwoo7 Sep 1, 2026
df00c03
fix(user): UNSIGNED BIGINT 상한 넘는 커서 id 거부 (PR #267 리뷰 반영)
chanwoo7 Sep 1, 2026
b40f8a7
Merge pull request #267 from CaQuick/feat/notification-center-alerts
chanwoo7 Sep 1, 2026
46a2543
feat(conversation): 구매자 문의 채팅 기반 — 진입 컨텍스트·메시지 전송·FAQ 자동응답·인사말
chanwoo7 Sep 1, 2026
897a8a8
fix(conversation): 대화 재사용 복구·레이스 재조회·SellerStore 인사말 노출 (PR #268 리뷰 반영)
chanwoo7 Sep 1, 2026
590ba92
fix(conversation): 인사말 초기화를 row 잠금 + 메시지 수 기준으로 직렬화 (PR #268 리뷰 반영)
chanwoo7 Sep 1, 2026
fbadab5
fix(conversation): 대화 생성을 메시지 트랜잭션과 원자화 (PR #268 리뷰 반영)
chanwoo7 Sep 1, 2026
0224c0d
fix(conversation): 트랜잭션 내 사전 조회를 tx 클라이언트로 (PR #268 리뷰 반영)
chanwoo7 Sep 1, 2026
fc2cc2d
Merge pull request #268 from CaQuick/feat/conversation-buyer-messaging
chanwoo7 Sep 1, 2026
0d87e11
feat(conversation): 알림센터 대화 탭 — 대화 목록·채팅 상세 조회·읽음 처리
chanwoo7 Sep 1, 2026
ac6b487
fix(conversation): 읽음 마커 고수위 갱신·커서 상한·목록 배치 조회 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
e0d5d64
fix(conversation): 읽음 마커를 커밋 순서와 정렬 + parseId 상한 검증 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
c641b39
fix(conversation): 구매자 mutation이 전달한 자동응답은 읽음 처리 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
07714f0
fix(conversation): 미읽음 백로그가 있으면 전송 시 읽음 마커 유지 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
490dba9
fix(common): 커서 timestamp에 MySQL DATETIME 상한 검증 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
628d942
fix(conversation): 미읽음 판정을 잠금 조회로 스냅샷 우회 (PR #269 리뷰 반영)
chanwoo7 Sep 1, 2026
ff07964
Merge pull request #269 from CaQuick/feat/conversation-buyer-list-detail
chanwoo7 Sep 1, 2026
d01ea4d
feat(conversation): 실시간 subscription — graphql-ws + Redis PubSub
chanwoo7 Sep 1, 2026
17884c9
fix(conversation): Redis 장애 격리·이벤트 최신 상태 발행 (PR #270 리뷰 반영)
chanwoo7 Sep 1, 2026
b3d3e42
fix(conversation): 목록 이벤트 스냅샷을 단일 트랜잭션으로 (PR #270 리뷰 반영)
chanwoo7 Sep 1, 2026
f2c5e13
fix(conversation): 커밋 후 발행 전체 격리 + 이벤트 순서 계약 명시 (PR #270 리뷰 반영)
chanwoo7 Sep 1, 2026
e028c61
fix(conversation): 목록 이벤트에 lastReadAt 실어 폐기 규칙 확장 + 스냅샷 매장명 (PR #270 …
chanwoo7 Sep 1, 2026
a474fa5
Merge pull request #270 from CaQuick/feat/conversation-subscriptions
chanwoo7 Sep 1, 2026
4826dac
fix: 릴리즈 리뷰 반영 (대화 메시지 시각을 DB 시계로 채번)
chanwoo7 Sep 2, 2026
a4a8ad2
fix: 릴리즈 리뷰 반영 (시계 컷오버 대비 대화 단위 단조 시각 보정)
chanwoo7 Sep 2, 2026
a94857a
fix: 릴리즈 리뷰 반영 (단조 시각 채번을 잠금 조회로)
chanwoo7 Sep 2, 2026
15a8d5c
Merge pull request #273 from CaQuick/fix/release-review-conversation-…
chanwoo7 Sep 2, 2026
5fbc2c1
fix: 릴리즈 리뷰 반영 (3개월 하한 월말 롤오버 클램프, 시드 포맷)
chanwoo7 Sep 2, 2026
108f57b
Merge pull request #274 from CaQuick/fix/release-review-cutoff-and-fo…
chanwoo7 Sep 2, 2026
1b6d2eb
fix: 릴리즈 리뷰 반영 (대화 목록 페이지·부가 정보를 단일 스냅샷으로)
chanwoo7 Sep 2, 2026
6db8b6f
fix: 릴리즈 리뷰 반영 (페이지 스냅샷 트랜잭션 격리 수준 명시)
chanwoo7 Sep 2, 2026
7ea51fb
Merge pull request #276 from CaQuick/fix/release-review-conversation-…
chanwoo7 Sep 2, 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ yarn start:dev
| --- | --- |
| **서버** | `NODE_ENV`, `PORT`, `BACKEND_BASE_URL`, `FRONTEND_BASE_URL` |
| **DB** | `DATABASE_URL` |
| **Redis (선택)** | `REDIS_URL` — GraphQL subscription PubSub. 미설정 시 `redis://localhost:6379`(로컬 docker-compose) |
| **JWT / Auth** | `JWT_ACCESS_SECRET`, `JWT_ACCESS_EXPIRES_SECONDS`, `AUTH_REFRESH_EXPIRES_DAYS`, `AUTH_COOKIE_DOMAIN`, `AUTH_COOKIE_SECURE` |
| **OIDC (Google)** | `OIDC_GOOGLE_CLIENT_ID`, `OIDC_GOOGLE_CLIENT_SECRET`, `OIDC_GOOGLE_ISSUER_URL` |
| **OIDC (Kakao)** | `OIDC_KAKAO_CLIENT_ID`, `OIDC_KAKAO_CLIENT_SECRET`, `OIDC_KAKAO_ISSUER_URL` |
Expand Down
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,17 @@ services:
timeout: 5s
retries: 5

redis:
image: redis:7-alpine
container_name: caquick-redis
# GraphQL subscription PubSub 백엔드. 영속화 불필요(휘발성 이벤트 브로커).
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5

volumes:
caquick-mysql-data:
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"graphql": "^16.14.0",
"graphql-redis-subscriptions": "^2.7.0",
"graphql-subscriptions": "^3.0.0",
"graphql-ws": "^6.2.1",
"ioredis": "^5.3.2",
"logform": "^2.7.0",
"openid-client": "5.7.1",
"passport": "^0.7.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `store` ADD COLUMN `greeting_message` VARCHAR(500) NULL;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ model Store {
website_url String? @db.VarChar(2048)
// 매장 프로필(로고) 이미지. 찜/매장별 보기 카드의 원형 프로필 표기용 (figma liked 02·04)
profile_image_url String? @db.VarChar(2048)
// 문의 채팅 인사말 템플릿({nickname}/{storeName} 치환). null이면 서버 기본 문구 (figma notification-center)
greeting_message String? @db.VarChar(500)
is_active Boolean @default(true) @db.TinyInt

created_at DateTime @default(now()) @db.DateTime(3)
Expand Down
6 changes: 5 additions & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PrismaClient } from '@prisma/client';

import { seedBanners } from './seed/banners';
import { seedCategories } from './seed/categories';
import { seedConversations } from './seed/conversations';
import { seedCustomDrafts } from './seed/custom-drafts';
import { resetSeedScope } from './seed/idempotent';
import { seedNotifications } from './seed/notifications';
Expand Down Expand Up @@ -61,7 +62,10 @@ async function main(): Promise<void> {
await seedRecentViews(prisma, { users, stores });

log('알림 시드 중...');
await seedNotifications(prisma, { users });
await seedNotifications(prisma, { users, stores, orders });

log('대화 + FAQ 시드 중...');
await seedConversations(prisma, { users, stores });

log('커스텀 드래프트 시드 중...');
await seedCustomDrafts(prisma, { users, stores });
Expand Down
166 changes: 166 additions & 0 deletions prisma/seed/conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* 시드 대화 + FAQ 칩 (figma notification-center 대화 탭·문의 채팅 재현).
*
* - storeA: 커스텀 인사말 + FAQ 5종(질문 칩) 등록.
* user1 대화 = 인사말 → 칩 질문("케이크 보관 방법") → HTML 자동응답.
* - storeB: 인사말 미설정(기본 문구 사용 경로 검증).
* user1 대화 = 인사말 → 유저 자유 텍스트 → 판매자 답장 3건(안읽음 배지 재현,
* last_read_at은 유저 메시지 시점까지만).
*/
import type { PrismaClient } from '@prisma/client';

import type { SeededStores } from './stores';
import type { SeededUser } from './users';

export async function seedConversations(
prisma: PrismaClient,
ctx: { users: SeededUser[]; stores: SeededStores },
): Promise<void> {
const user1 = ctx.users[0];
if (!user1) throw new Error('seedUsers must run before seedConversations');
const [storeA, storeB] = ctx.stores.stores;
if (!storeA || !storeB) {
throw new Error('seedStores must run before seedConversations');
}

const now = Date.now();
const hour = 60 * 60 * 1000;
const day = 24 * hour;

// ── storeA: 커스텀 인사말 + FAQ 칩 ──
await prisma.store.update({
where: { id: storeA.id },
data: {
greeting_message:
'안녕하세요! {nickname} 고객님.\n{storeName} 입니다 😄\n무엇을 도와드릴까요?',
},
});

const faqRows = [
{
title: '날짜 변경',
answer_html:
'<p>픽업 1일 전까지 채팅으로 요청해 주시면 일정 확인 후 변경해 드려요.</p>',
},
{
title: '케이크 보관 방법',
answer_html:
'<p>🎂 <strong>케이크 보관 방법</strong></p><ul><li>냉장보관시 최대 3일</li><li>생크림 케이크는 당일 드시는 걸 권장해요</li></ul>',
},
{
title: '가게 위치 정보',
answer_html: '<p>매장 상세의 찾아오는 길 안내를 확인해 주세요.</p>',
},
{
title: '제일 많이 물어보는 질문',
answer_html:
'<p>레터링 문구는 주문 시 요청사항에 남겨 주시면 반영돼요.</p>',
},
{
title: '예약 가능 일정',
answer_html:
'<p>캘린더에서 픽업 가능 날짜·시간대를 확인할 수 있어요.</p>',
},
];
const faqs = [] as { id: bigint; title: string; answer_html: string }[];
for (const [i, row] of faqRows.entries()) {
faqs.push(
await prisma.storeFaqTopic.create({
data: { store_id: storeA.id, sort_order: i + 1, ...row },
}),
);
}

// ── user1 ↔ storeA: 칩 문답 대화 ──
const convA = await prisma.storeConversation.create({
data: {
account_id: user1.id,
store_id: storeA.id,
last_message_at: new Date(now - 1 * day),
last_read_at: new Date(now - 1 * day),
},
});
const keepFaq = faqs[1];
await prisma.storeConversationMessage.createMany({
data: [
{
conversation_id: convA.id,
sender_type: 'STORE',
body_format: 'TEXT',
body_text:
'안녕하세요! seedTester1 고객님.\n[SEED] 케이크샵 A 입니다 😄\n무엇을 도와드릴까요?',
created_at: new Date(now - 1 * day - 2 * 60 * 1000),
},
{
conversation_id: convA.id,
sender_type: 'USER',
sender_account_id: user1.id,
body_format: 'TEXT',
body_text: keepFaq?.title ?? '케이크 보관 방법',
created_at: new Date(now - 1 * day - 60 * 1000),
},
{
conversation_id: convA.id,
sender_type: 'STORE',
body_format: 'HTML',
body_html: keepFaq?.answer_html ?? '<p>보관 안내</p>',
created_at: new Date(now - 1 * day),
},
],
});

// ── user1 ↔ storeB: 자유 문의 + 판매자 답장 3건(안읽음) ──
const convB = await prisma.storeConversation.create({
data: {
account_id: user1.id,
store_id: storeB.id,
last_message_at: new Date(now - 5 * hour),
// 유저가 마지막으로 읽은 시점 = 본인 메시지 직후 → 판매자 답장 3건 안읽음
last_read_at: new Date(now - 8 * hour),
},
});
await prisma.storeConversationMessage.createMany({
data: [
{
conversation_id: convB.id,
sender_type: 'STORE',
body_format: 'TEXT',
body_text:
'안녕하세요! seedTester1 고객님.\n[SEED] 도넛샵 B 입니다 😄\n무엇을 도와드릴까요?',
created_at: new Date(now - 9 * hour),
},
{
conversation_id: convB.id,
sender_type: 'USER',
sender_account_id: user1.id,
body_format: 'TEXT',
body_text: '주문한 도넛 픽업 시간을 30분 늦출 수 있을까요?',
created_at: new Date(now - 8 * hour),
},
{
conversation_id: convB.id,
sender_type: 'STORE',
sender_account_id: storeB.seller_account_id,
body_format: 'TEXT',
body_text: '고객님이 말씀해 주신 문의사항에 대한 답변 드리겠습니다.',
created_at: new Date(now - 7 * hour),
},
{
conversation_id: convB.id,
sender_type: 'STORE',
sender_account_id: storeB.seller_account_id,
body_format: 'TEXT',
body_text: '네, 30분 늦은 픽업 가능합니다.',
created_at: new Date(now - 6 * hour),
},
{
conversation_id: convB.id,
sender_type: 'STORE',
sender_account_id: storeB.seller_account_id,
body_format: 'TEXT',
body_text: '방문 시 매장 카운터에서 주문번호를 말씀해 주세요.',
created_at: new Date(now - 5 * hour),
},
],
});
}
33 changes: 33 additions & 0 deletions prisma/seed/idempotent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ export async function resetSeedScope(prisma: PrismaClient): Promise<void> {
});

// 알림
// 대화(메시지 → 본체). 시드 유저 소유 대화만 정리한다
const userConversations = await prisma.storeConversation.findMany({
where: { account_id: { in: userIds } },
select: { id: true },
});
const userConversationIds = userConversations.map((c) => c.id);
if (userConversationIds.length > 0) {
await prisma.storeConversationMessage.deleteMany({
where: { conversation_id: { in: userConversationIds } },
});
await prisma.storeConversation.deleteMany({
where: { id: { in: userConversationIds } },
});
}

await prisma.notification.deleteMany({
where: { account_id: { in: userIds } },
});
Expand Down Expand Up @@ -244,6 +259,24 @@ export async function resetSeedScope(prisma: PrismaClient): Promise<void> {
await prisma.product.deleteMany({ where: { id: { in: productIds } } });
}

// 시드 매장을 참조하는 대화(다른 유저 소유 포함)와 FAQ — store FK 선정리
const storeConversations = await prisma.storeConversation.findMany({
where: { store_id: { in: storeIds } },
select: { id: true },
});
const storeConversationIds = storeConversations.map((c) => c.id);
if (storeConversationIds.length > 0) {
await prisma.storeConversationMessage.deleteMany({
where: { conversation_id: { in: storeConversationIds } },
});
await prisma.storeConversation.deleteMany({
where: { id: { in: storeConversationIds } },
});
}
await prisma.storeFaqTopic.deleteMany({
where: { store_id: { in: storeIds } },
});

await prisma.storeBusinessHour.deleteMany({
where: { store_id: { in: storeIds } },
});
Expand Down
71 changes: 59 additions & 12 deletions prisma/seed/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,95 @@
/**
* 시드 알림 (user1, 3건). 읽음 1 / 안읽음 2 → unreadNotificationCount=2.
* 시드 알림 (user1, 5건).
* - 3개월 내 4건(읽음 1 / 안읽음 3 → unreadNotificationCount=3):
* 주문확정·제작완료·픽업완료·리뷰 좋아요 — 알림센터 4종 이벤트 재현.
* - 3개월 경과 1건: myNotifications 3개월 노출 필터 검증용(목록·배지에서 제외).
* 문구는 notification feature 상수(figma notification-center 톤)와 동일하게 유지.
*/
import type { PrismaClient } from '@prisma/client';

import type { SeededOrders } from './orders';
import type { SeededStores } from './stores';
import type { SeededUser } from './users';

export async function seedNotifications(
prisma: PrismaClient,
ctx: { users: SeededUser[] },
ctx: { users: SeededUser[]; stores: SeededStores; orders: SeededOrders },
): Promise<void> {
const user1 = ctx.users[0];
if (!user1) throw new Error('seedUsers must run before seedNotifications');
const [p1, p2, p3] = ctx.stores.products;
if (!p1 || !p2 || !p3) {
throw new Error('seedStores must run before seedNotifications');
}

// 리뷰 좋아요 알림은 seedReviews가 만든 user1 리뷰(p1, storeA)에 연결한다.
const review = await prisma.review.findFirstOrThrow({
where: { account_id: user1.id, product_id: p1.id },
select: { id: true, store_id: true, product_id: true },
});

const now = Date.now();
const hour = 60 * 60 * 1000;
const day = 24 * hour;

await prisma.notification.createMany({
data: [
{
account_id: user1.id,
type: 'REVIEW_LIKE',
event: 'REVIEW_LIKED',
title: '리뷰 좋아요',
body: '다른 사람이 내가 남긴 리뷰를 좋아했어요.',
review_id: review.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete seeded notifications before their FK targets

On the second yarn prisma:seed run, this new review_id (and the new order_id values below) prevents resetSeedScope from being idempotent: that function deletes the seeded reviews and orders before deleting the user's notifications, while the Notification.review and Notification.order relations have no cascading delete. MySQL therefore rejects the first referenced review/order deletion with a foreign-key constraint error. Move notification cleanup ahead of review/order cleanup, or otherwise clear these references first.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false positive: notification의 review_id/order_id/store_id/product_id FK는 모두 ON DELETE SET NULL (migrations/20260209192020 L114~L126). 리뷰/주문을 먼저 삭제해도 알림 컬럼이 NULL로 풀릴 뿐 FK 거부가 발생하지 않음. yarn prisma:seed 2회 연속 실행으로 멱등성 실검증 완료(PR #268 본문 참조).

store_id: review.store_id,
product_id: review.product_id,
read_at: null,
created_at: new Date(now - 1 * hour),
},
{
account_id: user1.id,
type: 'ORDER_STATUS',
event: 'ORDER_CONFIRMED',
title: '주문이 확정되었습니다',
body: 'SEED-O2-CONF 주문이 확정되었습니다.',
title: '주문확정',
body: 'SEED-O2-CONF 주문이 확정되었어요.',
order_id: ctx.orders.o2Confirmed,
store_id: p2.store_id,
product_id: p2.id,
read_at: null,
created_at: new Date(now - 1 * hour),
created_at: new Date(now - 5 * hour),
},
{
account_id: user1.id,
type: 'ORDER_STATUS',
event: 'ORDER_MADE',
title: '주문이 제작 완료되었습니다',
body: 'SEED-O3-MADE 주문의 상품 제작이 완료되었습니다.',
title: '제작완료',
body: 'SEED-O3-MADE 주문하신 케이크 제작이 완료되었어요.',
order_id: ctx.orders.o3Made,
store_id: p3.store_id,
product_id: p3.id,
read_at: null,
created_at: new Date(now - 24 * hour),
created_at: new Date(now - 1 * day),
},
{
// 연관 ID 미저장 과거 주문 알림 재현 — 조회 시 order.items 폴백 검증용.
account_id: user1.id,
type: 'ORDER_STATUS',
event: 'ORDER_PICKED_UP',
title: '주문이 픽업 처리되었습니다',
body: 'SEED-O4-PICKED-RE 주문이 픽업 완료 처리되었습니다.',
read_at: new Date(now - 9 * 24 * hour),
created_at: new Date(now - 10 * 24 * hour),
title: '픽업완료',
body: 'SEED-O4-PICKED-RE 케이크 픽업이 완료되었어요.',
order_id: ctx.orders.o4PickedUpReviewed,
read_at: new Date(now - 9 * day),
created_at: new Date(now - 10 * day),
},
{
// 3개월(+7일) 경과 — 알림센터 목록·배지 어디에도 노출되지 않아야 한다.
account_id: user1.id,
type: 'ORDER_STATUS',
event: 'ORDER_PICKED_UP',
title: '픽업완료',
body: 'SEED-OLD 케이크 픽업이 완료되었어요.',
read_at: null,
created_at: new Date(now - 97 * day),
},
],
});
Expand Down
Loading
Loading