diff --git a/docker-compose.yml b/docker-compose.yml index 444c56a..b62f95b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,13 @@ services: mysql: image: mysql:8.0 container_name: caquick-mysql + # MySQL 8 기본 caching_sha2_password 가 prisma client(현 6.x)에서 + # 일부 환경에서 sha256_password 로 잘못 인식되어 연결이 실패하는 케이스가 + # 있어 mysql_native_password 로 통일한다. 새로 생성되는 사용자도 동일 plugin. + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: CaQuick diff --git a/docker/mysql/init/01-grant-shadow-db.sql b/docker/mysql/init/01-grant-shadow-db.sql index b77f3d1..efbb3ff 100644 --- a/docker/mysql/init/01-grant-shadow-db.sql +++ b/docker/mysql/init/01-grant-shadow-db.sql @@ -1,4 +1,10 @@ -- Prisma migrate dev는 shadow database를 임시 생성/삭제하므로 -- caquick 유저에게 DB 생성 권한을 부여한다. GRANT ALL PRIVILEGES ON *.* TO 'caquick'@'%'; + +-- prisma client 6.x가 일부 환경에서 caching_sha2_password를 +-- sha256_password로 잘못 인식하는 호환 이슈가 있어 native_password로 통일. +-- (docker-compose의 --default-authentication-plugin과 이중 안전망) +ALTER USER 'caquick'@'%' IDENTIFIED WITH mysql_native_password BY 'caquick'; + FLUSH PRIVILEGES; diff --git a/package.json b/package.json index a886272..ae33b43 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,14 @@ "prisma:migrate:create": "prisma migrate dev --create-only", "prisma:migrate:deploy": "prisma migrate deploy", "prisma:studio": "prisma studio", + "prisma:seed": "prisma db seed", "graphql:codegen": "graphql-codegen --config codegen.yml", "graphql:docs": "spectaql -c spectaql.yml", "prepare": "husky" }, + "prisma": { + "seed": "ts-node -r tsconfig-paths/register prisma/seed.ts" + }, "dependencies": { "@apollo/server": "^5.5.0", "@as-integrations/express5": "^1.1.2", diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..590547d --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,82 @@ +/** + * 마이페이지 검증용 시드 데이터. + * + * 실행: yarn prisma:seed + * + * - 기존 시드 영역(seed-user-* 이메일, [SEED] * 매장명)을 정리한 뒤 재삽입한다 (idempotent). + * - production 환경에서는 자동 차단된다. + * + * 발급된 테스트 accountId가 콘솔에 출력되며, GraphQL Playground에서 dev 토큰 + * 발급 헬퍼(POST /auth/dev/issue-token)와 함께 사용한다. + */ +import { PrismaClient } from '@prisma/client'; + +import { seedCustomDrafts } from './seed/custom-drafts'; +import { resetSeedScope } from './seed/idempotent'; +import { seedNotifications } from './seed/notifications'; +import { seedOrders } from './seed/orders'; +import { seedRecentViews } from './seed/recent-views'; +import { seedReviews } from './seed/reviews'; +import { seedSearchHistory } from './seed/search-history'; +import { seedStores } from './seed/stores'; +import { seedUsers } from './seed/users'; +import { seedWishlist } from './seed/wishlist'; + +async function main(): Promise { + if (process.env.NODE_ENV === 'production') { + throw new Error('seed는 production 환경에서 실행할 수 없습니다.'); + } + + const prisma = new PrismaClient(); + try { + log('기존 시드 영역 정리 중...'); + await resetSeedScope(prisma); + + log('유저 + 프로필 시드 중...'); + const users = await seedUsers(prisma); + + log('매장 + 상품 시드 중...'); + const stores = await seedStores(prisma); + + log('주문 + 아이템 시드 중...'); + const orders = await seedOrders(prisma, { users, stores }); + + log('리뷰 시드 중...'); + await seedReviews(prisma, { users, stores, orders }); + + log('찜 시드 중...'); + await seedWishlist(prisma, { users, stores }); + + log('최근 본 상품 시드 중...'); + await seedRecentViews(prisma, { users, stores }); + + log('알림 시드 중...'); + await seedNotifications(prisma, { users }); + + log('커스텀 드래프트 시드 중...'); + await seedCustomDrafts(prisma, { users, stores }); + + log('검색 히스토리 시드 중...'); + await seedSearchHistory(prisma, { users }); + + log('완료. 발급된 테스트 계정:'); + for (const u of users) { + const status = + u.name === null + ? '온보딩 미완료 (needsProfile=true 검증용)' + : '온보딩 완료'; + log(` - accountId=${u.id.toString()} email=${u.email} (${status})`); + } + } finally { + await prisma.$disconnect(); + } +} + +function log(message: string): void { + console.log(`[seed] ${message}`); +} + +main().catch((err: unknown) => { + console.error('[seed] 실패:', err); + process.exit(1); +}); diff --git a/prisma/seed/custom-drafts.ts b/prisma/seed/custom-drafts.ts new file mode 100644 index 0000000..96f4cc3 --- /dev/null +++ b/prisma/seed/custom-drafts.ts @@ -0,0 +1,31 @@ +/** + * 시드 커스텀 드래프트 (user1, 2건). customDraftCount=2. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedCustomDrafts( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedCustomDrafts'); + const [p1, , p3] = ctx.stores.products; + + await prisma.customDraft.createMany({ + data: [ + { + account_id: user1.id, + product_id: p1.id, + status: 'IN_PROGRESS', + }, + { + account_id: user1.id, + product_id: p3.id, + status: 'READY_FOR_ORDER', + }, + ], + }); +} diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts new file mode 100644 index 0000000..aac045a --- /dev/null +++ b/prisma/seed/idempotent.ts @@ -0,0 +1,262 @@ +/** + * 시드 데이터 정리(idempotent) 헬퍼. + * + * 시드는 다음 식별자들로만 자기 영역을 구분한다: + * - 유저 이메일: SEED_USER_EMAIL_PREFIX (`seed-user-`) + * - 매장 이름: SEED_STORE_NAME_PREFIX (`[SEED] `) + * + * 정리 시 위 prefix에 매칭되는 row와 그 종속 데이터(주문/리뷰/찜/...)를 + * 삭제한 뒤 다시 삽입하므로, 수동으로 만든 다른 데이터는 보존된다. + */ +import type { PrismaClient } from '@prisma/client'; + +export const SEED_USER_EMAIL_PREFIX = 'seed-user-'; +export const SEED_STORE_NAME_PREFIX = '[SEED] '; + +export async function resetSeedScope(prisma: PrismaClient): Promise { + const seedUsers = await prisma.account.findMany({ + where: { email: { startsWith: SEED_USER_EMAIL_PREFIX } }, + select: { id: true }, + }); + const userIds = seedUsers.map((u) => u.id); + + const seedStores = await prisma.store.findMany({ + where: { store_name: { startsWith: SEED_STORE_NAME_PREFIX } }, + select: { id: true, seller_account_id: true }, + }); + const storeIds = seedStores.map((s) => s.id); + const sellerAccountIds = seedStores.map((s) => s.seller_account_id); + + // 1) 유저 종속 (주문, 리뷰, 찜, 최근본, 알림, 드래프트, 검색기록, 카트, 인증세션) + if (userIds.length > 0) { + // 주문 종속들 → 주문 본체 (FK depth가 깊으므로 안에서 다시 처리) + const userOrders = await prisma.order.findMany({ + where: { account_id: { in: userIds } }, + select: { id: true }, + }); + const orderIds = userOrders.map((o) => o.id); + if (orderIds.length > 0) { + const orderItems = await prisma.orderItem.findMany({ + where: { order_id: { in: orderIds } }, + select: { id: true }, + }); + const orderItemIds = orderItems.map((i) => i.id); + if (orderItemIds.length > 0) { + // OrderItemCustomFreeEdit -> attachments -> deletes + const freeEdits = await prisma.orderItemCustomFreeEdit.findMany({ + where: { order_item_id: { in: orderItemIds } }, + select: { id: true }, + }); + const freeEditIds = freeEdits.map((f) => f.id); + if (freeEditIds.length > 0) { + await prisma.orderItemCustomFreeEditAttachment.deleteMany({ + where: { free_edit_id: { in: freeEditIds } }, + }); + } + await prisma.orderItemCustomFreeEdit.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + await prisma.orderItemCustomText.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + await prisma.orderItemOptionItem.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + // 리뷰 (OrderItem fk + Review fk) + await prisma.reviewLike.deleteMany({ + where: { review: { order_item_id: { in: orderItemIds } } }, + }); + await prisma.reviewMedia.deleteMany({ + where: { review: { order_item_id: { in: orderItemIds } } }, + }); + await prisma.review.deleteMany({ + where: { order_item_id: { in: orderItemIds } }, + }); + } + await prisma.orderItem.deleteMany({ + where: { order_id: { in: orderIds } }, + }); + await prisma.orderStatusHistory.deleteMany({ + where: { order_id: { in: orderIds } }, + }); + await prisma.order.deleteMany({ where: { id: { in: orderIds } } }); + } + + // 리뷰: 주문 종속에서 이미 처리되었지만, 다른 리뷰가 있을 수 있음 → 사용자 기준으로도 한 번 더 + await prisma.reviewLike.deleteMany({ + where: { account_id: { in: userIds } }, + }); + await prisma.reviewMedia.deleteMany({ + where: { review: { account_id: { in: userIds } } }, + }); + await prisma.review.deleteMany({ where: { account_id: { in: userIds } } }); + + // 찜 + await prisma.wishlistItem.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 최근 본 + await prisma.recentProductView.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 알림 + await prisma.notification.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 커스텀 드래프트 + await prisma.customDraft.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 검색 히스토리 + await prisma.searchHistory.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 카트 + const userCarts = await prisma.cart.findMany({ + where: { account_id: { in: userIds } }, + select: { id: true }, + }); + const cartIds = userCarts.map((c) => c.id); + if (cartIds.length > 0) { + const cartItems = await prisma.cartItem.findMany({ + where: { cart_id: { in: cartIds } }, + select: { id: true }, + }); + const cartItemIds = cartItems.map((i) => i.id); + if (cartItemIds.length > 0) { + await prisma.cartItemOptionItem.deleteMany({ + where: { cart_item_id: { in: cartItemIds } }, + }); + } + await prisma.cartItem.deleteMany({ where: { cart_id: { in: cartIds } } }); + await prisma.cart.deleteMany({ where: { id: { in: cartIds } } }); + } + + // 인증 세션 + await prisma.authRefreshSession.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 계정 ID 연결 해제 + await prisma.accountIdentity.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 프로필 + await prisma.userProfile.deleteMany({ + where: { account_id: { in: userIds } }, + }); + + // 계정 본체 + await prisma.account.deleteMany({ where: { id: { in: userIds } } }); + } + + // 2) 매장 종속 (상품, 영업시간 등) + if (storeIds.length > 0) { + const storeProducts = await prisma.product.findMany({ + where: { store_id: { in: storeIds } }, + select: { id: true }, + }); + const productIds = storeProducts.map((p) => p.id); + if (productIds.length > 0) { + // 옵션 + const optionGroups = await prisma.productOptionGroup.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const optionGroupIds = optionGroups.map((g) => g.id); + if (optionGroupIds.length > 0) { + await prisma.productOptionItem.deleteMany({ + where: { option_group_id: { in: optionGroupIds } }, + }); + } + await prisma.productOptionGroup.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + // 커스텀 템플릿 + const templates = await prisma.productCustomTemplate.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const templateIds = templates.map((t) => t.id); + if (templateIds.length > 0) { + await prisma.productCustomTextToken.deleteMany({ + where: { template_id: { in: templateIds } }, + }); + } + await prisma.productCustomTemplate.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + await prisma.productImage.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.productCategory.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.productTag.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + // 다른 사용자의 cartItem/wishlist/recentView/customDraft에 이 product가 참조될 수 있음 + // 시드 store의 product를 안전하게 지우려면 외부 참조도 같이 정리 + const sharedItems = await prisma.cartItem.findMany({ + where: { product_id: { in: productIds } }, + select: { id: true }, + }); + const sharedItemIds = sharedItems.map((i) => i.id); + if (sharedItemIds.length > 0) { + await prisma.cartItemOptionItem.deleteMany({ + where: { cart_item_id: { in: sharedItemIds } }, + }); + } + await prisma.cartItem.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.wishlistItem.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.recentProductView.deleteMany({ + where: { product_id: { in: productIds } }, + }); + await prisma.customDraft.deleteMany({ + where: { product_id: { in: productIds } }, + }); + + await prisma.product.deleteMany({ where: { id: { in: productIds } } }); + } + + await prisma.storeBusinessHour.deleteMany({ + where: { store_id: { in: storeIds } }, + }); + await prisma.storeSpecialClosure.deleteMany({ + where: { store_id: { in: storeIds } }, + }); + await prisma.store.deleteMany({ where: { id: { in: storeIds } } }); + } + + // 3) 매장의 seller account 본체 + if (sellerAccountIds.length > 0) { + await prisma.sellerCredential.deleteMany({ + where: { seller_account_id: { in: sellerAccountIds } }, + }); + await prisma.sellerProfile.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.accountIdentity.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.authRefreshSession.deleteMany({ + where: { account_id: { in: sellerAccountIds } }, + }); + await prisma.account.deleteMany({ + where: { id: { in: sellerAccountIds } }, + }); + } +} diff --git a/prisma/seed/notifications.ts b/prisma/seed/notifications.ts new file mode 100644 index 0000000..52d58fc --- /dev/null +++ b/prisma/seed/notifications.ts @@ -0,0 +1,49 @@ +/** + * 시드 알림 (user1, 3건). 읽음 1 / 안읽음 2 → unreadNotificationCount=2. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +export async function seedNotifications( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedNotifications'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + + await prisma.notification.createMany({ + data: [ + { + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_CONFIRMED', + title: '주문이 확정되었습니다', + body: 'SEED-O2-CONF 주문이 확정되었습니다.', + read_at: null, + created_at: new Date(now - 1 * hour), + }, + { + account_id: user1.id, + type: 'ORDER_STATUS', + event: 'ORDER_MADE', + title: '주문이 제작 완료되었습니다', + body: 'SEED-O3-MADE 주문의 상품 제작이 완료되었습니다.', + read_at: null, + created_at: new Date(now - 24 * hour), + }, + { + 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), + }, + ], + }); +} diff --git a/prisma/seed/orders.ts b/prisma/seed/orders.ts new file mode 100644 index 0000000..b92fe25 --- /dev/null +++ b/prisma/seed/orders.ts @@ -0,0 +1,360 @@ +/** + * 시드 주문 + 아이템 + 상태 히스토리. + * + * user1 기준 6건: + * o1 SUBMITTED - p1×1 + * o2 CONFIRMED - p2×1 + selectedOptions(레귤러) + * o3 MADE - p3×2 + * o4 PICKED_UP - p1×1 (리뷰 작성됨 → hasReviewableItem=false, hasMyReview=true) + * o5 PICKED_UP - p4×1 (리뷰 미작성 → hasReviewableItem=true) + * o6 CANCELED - p2×1 + */ +import type { OrderStatus, PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export interface SeededOrders { + o1Submitted: bigint; + o2Confirmed: bigint; + o3Made: bigint; + o4PickedUpReviewed: bigint; + o4OrderItemId: bigint; + o5PickedUpReviewable: bigint; + o6Canceled: bigint; +} + +export async function seedOrders( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedOrders'); + const [storeA, storeB] = ctx.stores.stores; + const [p1, p2, p3, p4] = ctx.stores.products; + const { p2GroupId, p2OptionItemId } = ctx.stores.optionGroupIds; + + const day = 24 * 60 * 60 * 1000; + const now = new Date(); + const buyerName = user1.name ?? '테스트 유저'; + const buyerPhone = '010-1111-2222'; + + // ── o1: SUBMITTED (방금 주문) ── + const o1 = await prisma.order.create({ + data: { + order_number: 'SEED-O1-SUB', + account_id: user1.id, + status: 'SUBMITTED', + pickup_at: new Date(now.getTime() + 3 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 35000, + discount_price: 0, + total_price: 35000, + submitted_at: now, + items: { + create: [ + { + store_id: storeA.id, + product_id: p1.id, + product_name_snapshot: p1.name, + regular_price_snapshot: p1.regular_price, + sale_price_snapshot: p1.sale_price, + quantity: 1, + item_subtotal_price: 35000, + }, + ], + }, + status_histories: { + create: [ + { from_status: null, to_status: 'SUBMITTED', changed_at: now }, + ], + }, + }, + }); + + // ── o2: CONFIRMED (어제 확정) ── + const o2SubmittedAt = new Date(now.getTime() - 2 * day); + const o2ConfirmedAt = new Date(now.getTime() - 1 * day); + const o2 = await prisma.order.create({ + data: { + order_number: 'SEED-O2-CONF', + account_id: user1.id, + status: 'CONFIRMED', + pickup_at: new Date(now.getTime() + 4 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 60000, + discount_price: 0, + total_price: 60000, + submitted_at: o2SubmittedAt, + confirmed_at: o2ConfirmedAt, + status_histories: { + create: [ + { + from_status: null, + to_status: 'SUBMITTED', + changed_at: o2SubmittedAt, + }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o2ConfirmedAt, + }, + ], + }, + }, + }); + const o2Item = await prisma.orderItem.create({ + data: { + order_id: o2.id, + store_id: storeA.id, + product_id: p2.id, + product_name_snapshot: p2.name, + regular_price_snapshot: p2.regular_price, + sale_price_snapshot: p2.sale_price, + quantity: 1, + item_subtotal_price: 60000, + }, + }); + await prisma.orderItemOptionItem.create({ + data: { + order_item_id: o2Item.id, + option_group_id: p2GroupId, + option_item_id: p2OptionItemId, + group_name_snapshot: '케이크 사이즈', + option_title_snapshot: '레귤러 (6호)', + option_price_delta_snapshot: 10000, + }, + }); + // 커스텀 텍스트 1건 + 자유 편집 1건 (주문 상세 검증용) + await prisma.orderItemCustomText.create({ + data: { + order_item_id: o2Item.id, + token_key_snapshot: 'message', + default_text_snapshot: '메시지를 입력하세요', + value_text: '생일 축하해 :)', + sort_order: 0, + }, + }); + const o2FreeEdit = await prisma.orderItemCustomFreeEdit.create({ + data: { + order_item_id: o2Item.id, + crop_image_url: 'https://placehold.co/300x300/png?text=Custom+Crop', + description_text: '이 사진을 케이크 위에 그려주세요', + sort_order: 0, + }, + }); + await prisma.orderItemCustomFreeEditAttachment.create({ + data: { + free_edit_id: o2FreeEdit.id, + image_url: 'https://placehold.co/300x300/png?text=Reference', + sort_order: 0, + }, + }); + + // ── o3: MADE (제작 완료, 픽업 임박) ── + const o3Submitted = new Date(now.getTime() - 4 * day); + const o3Confirmed = new Date(now.getTime() - 3 * day); + const o3Made = new Date(now.getTime() - 1 * day); + const o3 = await prisma.order.create({ + data: { + order_number: 'SEED-O3-MADE', + account_id: user1.id, + status: 'MADE', + pickup_at: new Date(now.getTime() + 1 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 50000, + discount_price: 6000, + total_price: 44000, + submitted_at: o3Submitted, + confirmed_at: o3Confirmed, + made_at: o3Made, + items: { + create: [ + { + store_id: storeA.id, + product_id: p3.id, + product_name_snapshot: p3.name, + regular_price_snapshot: p3.regular_price, + sale_price_snapshot: p3.sale_price, + quantity: 2, + item_subtotal_price: 44000, + }, + ], + }, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o3Submitted }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o3Confirmed, + }, + { + from_status: 'CONFIRMED', + to_status: 'MADE', + changed_at: o3Made, + }, + ], + }, + }, + }); + + // ── o4: PICKED_UP (리뷰 작성 완료) ── + const o4Sub = new Date(now.getTime() - 14 * day); + const o4Conf = new Date(now.getTime() - 13 * day); + const o4Made = new Date(now.getTime() - 11 * day); + const o4Picked = new Date(now.getTime() - 10 * day); + const o4 = await prisma.order.create({ + data: { + order_number: 'SEED-O4-PICKED-RE', + account_id: user1.id, + status: 'PICKED_UP', + pickup_at: o4Picked, + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 35000, + discount_price: 0, + total_price: 35000, + submitted_at: o4Sub, + confirmed_at: o4Conf, + made_at: o4Made, + picked_up_at: o4Picked, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o4Sub }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: o4Conf, + }, + { from_status: 'CONFIRMED', to_status: 'MADE', changed_at: o4Made }, + { from_status: 'MADE', to_status: 'PICKED_UP', changed_at: o4Picked }, + ], + }, + }, + }); + const o4Item = await prisma.orderItem.create({ + data: { + order_id: o4.id, + store_id: storeA.id, + product_id: p1.id, + product_name_snapshot: p1.name, + regular_price_snapshot: p1.regular_price, + sale_price_snapshot: p1.sale_price, + quantity: 1, + item_subtotal_price: 35000, + }, + }); + + // ── o5: PICKED_UP (리뷰 미작성 → hasReviewableItem=true) ── + const o5Picked = new Date(now.getTime() - 5 * day); + const o5 = await prisma.order.create({ + data: { + order_number: 'SEED-O5-PICKED-OK', + account_id: user1.id, + status: 'PICKED_UP', + pickup_at: o5Picked, + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 3000, + discount_price: 0, + total_price: 3000, + submitted_at: new Date(now.getTime() - 8 * day), + confirmed_at: new Date(now.getTime() - 7 * day), + made_at: new Date(now.getTime() - 6 * day), + picked_up_at: o5Picked, + items: { + create: [ + { + store_id: storeB.id, + product_id: p4.id, + product_name_snapshot: p4.name, + regular_price_snapshot: p4.regular_price, + sale_price_snapshot: null, + quantity: 1, + item_subtotal_price: 3000, + }, + ], + }, + status_histories: { + create: [ + { + to_status: 'SUBMITTED', + changed_at: new Date(now.getTime() - 8 * day), + }, + { + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: new Date(now.getTime() - 7 * day), + }, + { + from_status: 'CONFIRMED', + to_status: 'MADE', + changed_at: new Date(now.getTime() - 6 * day), + }, + { + from_status: 'MADE', + to_status: 'PICKED_UP', + changed_at: o5Picked, + }, + ], + }, + }, + }); + + // ── o6: CANCELED ── + const o6Sub = new Date(now.getTime() - 6 * day); + const o6Cancel = new Date(now.getTime() - 5 * day); + const o6 = await prisma.order.create({ + data: { + order_number: 'SEED-O6-CANCEL', + account_id: user1.id, + status: 'CANCELED', + pickup_at: new Date(now.getTime() - 3 * day), + buyer_name: buyerName, + buyer_phone: buyerPhone, + subtotal_price: 50000, + discount_price: 0, + total_price: 50000, + submitted_at: o6Sub, + canceled_at: o6Cancel, + items: { + create: [ + { + store_id: storeA.id, + product_id: p2.id, + product_name_snapshot: p2.name, + regular_price_snapshot: p2.regular_price, + sale_price_snapshot: null, + quantity: 1, + item_subtotal_price: 50000, + }, + ], + }, + status_histories: { + create: [ + { to_status: 'SUBMITTED', changed_at: o6Sub }, + { + from_status: 'SUBMITTED', + to_status: 'CANCELED', + changed_at: o6Cancel, + note: '재료 수급 불가', + }, + ], + }, + }, + }); + + return { + o1Submitted: o1.id, + o2Confirmed: o2.id, + o3Made: o3.id, + o4PickedUpReviewed: o4.id, + o4OrderItemId: o4Item.id, + o5PickedUpReviewable: o5.id, + o6Canceled: o6.id, + }; +} diff --git a/prisma/seed/recent-views.ts b/prisma/seed/recent-views.ts new file mode 100644 index 0000000..49ce19f --- /dev/null +++ b/prisma/seed/recent-views.ts @@ -0,0 +1,45 @@ +/** + * 시드 최근 본 상품 (user1, 4건). + * viewed_at에 시간 차이를 두어 정렬 검증 가능. + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedRecentViews( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedRecentViews'); + const [p1, p2, p3, p4] = ctx.stores.products; + + const now = Date.now(); + const minute = 60 * 1000; + + await prisma.recentProductView.createMany({ + data: [ + { + account_id: user1.id, + product_id: p1.id, + viewed_at: new Date(now - 5 * minute), + }, + { + account_id: user1.id, + product_id: p2.id, + viewed_at: new Date(now - 30 * minute), + }, + { + account_id: user1.id, + product_id: p3.id, + viewed_at: new Date(now - 2 * 60 * minute), + }, + { + account_id: user1.id, + product_id: p4.id, + viewed_at: new Date(now - 24 * 60 * minute), + }, + ], + }); +} diff --git a/prisma/seed/reviews.ts b/prisma/seed/reviews.ts new file mode 100644 index 0000000..35a1634 --- /dev/null +++ b/prisma/seed/reviews.ts @@ -0,0 +1,48 @@ +/** + * 시드 리뷰. user1이 o4의 OrderItem에 작성한 1건. + * IMAGE 1건 + VIDEO 1건 첨부. + */ +import type { PrismaClient } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import type { SeededOrders } from './orders'; +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedReviews( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores; orders: SeededOrders }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedReviews'); + const [storeA] = ctx.stores.stores; + const [p1] = ctx.stores.products; + const orderItemId = ctx.orders.o4OrderItemId; + + await prisma.review.create({ + data: { + order_item_id: orderItemId, + account_id: user1.id, + store_id: storeA.id, + product_id: p1.id, + rating: new Prisma.Decimal('4.5'), + content: + '레터링이 정말 예쁘게 나왔어요. 케이크 맛도 좋고 다음에 또 주문할게요!', + media: { + create: [ + { + media_type: 'IMAGE', + media_url: 'https://placehold.co/800x800/png?text=Review+Image', + sort_order: 0, + }, + { + media_type: 'VIDEO', + media_url: 'https://placehold.co/800x800/png?text=Review+Video.mp4', + thumbnail_url: 'https://placehold.co/300x300/png?text=Video+Thumb', + sort_order: 1, + }, + ], + }, + }, + }); +} diff --git a/prisma/seed/search-history.ts b/prisma/seed/search-history.ts new file mode 100644 index 0000000..5ac6d2f --- /dev/null +++ b/prisma/seed/search-history.ts @@ -0,0 +1,37 @@ +/** + * 시드 검색 히스토리 (user1, 3건). + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +export async function seedSearchHistory( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedSearchHistory'); + + const now = Date.now(); + const hour = 60 * 60 * 1000; + + await prisma.searchHistory.createMany({ + data: [ + { + account_id: user1.id, + keyword: '레터링 케이크', + last_used_at: new Date(now - 1 * hour), + }, + { + account_id: user1.id, + keyword: '도넛', + last_used_at: new Date(now - 5 * hour), + }, + { + account_id: user1.id, + keyword: '캐릭터', + last_used_at: new Date(now - 24 * hour), + }, + ], + }); +} diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts new file mode 100644 index 0000000..175a9c7 --- /dev/null +++ b/prisma/seed/stores.ts @@ -0,0 +1,187 @@ +/** + * 시드 매장 + 상품. + * + * 매장 2개 (각각 SELLER 계정 1개씩 생성), 상품 5개: + * p1 레터링 케이크 (sale_price 있음) + * p2 캐릭터 케이크 (sale_price 없음, 옵션 그룹 포함) + * p3 미니 케이크 세트 + * p4 글레이즈드 도넛 (다른 매장) + * p5 비활성 상품 (찜 가시성 검증용) + */ +import type { PrismaClient, Product, Store } from '@prisma/client'; + +import { SEED_STORE_NAME_PREFIX } from './idempotent'; + +export interface SeededStores { + stores: Store[]; + products: Product[]; + optionGroupIds: { p2GroupId: bigint; p2OptionItemId: bigint }; +} + +export async function seedStores(prisma: PrismaClient): Promise { + // 매장 1: 케이크샵 A (소속 seller 계정 자동 생성) + const sellerA = await prisma.account.create({ + data: { + account_type: 'SELLER', + status: 'ACTIVE', + email: 'seed-seller-a@dev.caquick', + name: '케이크샵 A 운영자', + }, + }); + const storeA = await prisma.store.create({ + data: { + seller_account_id: sellerA.id, + store_name: `${SEED_STORE_NAME_PREFIX}케이크샵 A`, + store_phone: '02-1111-2222', + address_full: '서울특별시 강남구 테헤란로 1길 10', + address_city: '서울특별시', + address_district: '강남구', + address_neighborhood: '역삼동', + latitude: 37.5012 as unknown as never, + longitude: 127.0396 as unknown as never, + business_hours_text: '매일 09:00 ~ 18:00 (화요일 정기 휴무)', + is_active: true, + }, + }); + + // 매장 2: 도넛샵 B + const sellerB = await prisma.account.create({ + data: { + account_type: 'SELLER', + status: 'ACTIVE', + email: 'seed-seller-b@dev.caquick', + name: '도넛샵 B 운영자', + }, + }); + const storeB = await prisma.store.create({ + data: { + seller_account_id: sellerB.id, + store_name: `${SEED_STORE_NAME_PREFIX}도넛샵 B`, + store_phone: '02-3333-4444', + address_full: '서울특별시 마포구 와우산로 5', + address_city: '서울특별시', + address_district: '마포구', + address_neighborhood: '서교동', + business_hours_text: '평일 11:00 ~ 21:00', + is_active: true, + }, + }); + + // 상품 + const p1 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 레터링 케이크', + description: '원하는 글씨를 손글씨로 새겨주는 레터링 케이크입니다.', + regular_price: 40000, + sale_price: 35000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Lettering+Cake', + sort_order: 0, + }, + ], + }, + }, + }); + + const p2 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 캐릭터 케이크', + description: '원하시는 캐릭터를 케이크 위에 그려드립니다.', + regular_price: 50000, + sale_price: null, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Character+Cake', + sort_order: 0, + }, + ], + }, + }, + }); + + // p2에 옵션 그룹 1건 + 옵션 아이템 2건 (주문 상세 selectedOptions 검증) + const p2OptionGroup = await prisma.productOptionGroup.create({ + data: { + product_id: p2.id, + name: '케이크 사이즈', + is_required: true, + min_select: 1, + max_select: 1, + sort_order: 0, + is_active: true, + option_items: { + create: [ + { title: '미니 (4호)', price_delta: 0, sort_order: 0 }, + { title: '레귤러 (6호)', price_delta: 10000, sort_order: 1 }, + ], + }, + }, + include: { option_items: true }, + }); + const p2RegularItem = p2OptionGroup.option_items.find( + (i) => i.title === '레귤러 (6호)', + )!; + + const p3 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] 미니 케이크 세트', + description: '한 입 크기의 미니 케이크 6종 세트.', + regular_price: 25000, + sale_price: 22000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Mini+Cake+Set', + sort_order: 0, + }, + ], + }, + }, + }); + + const p4 = await prisma.product.create({ + data: { + store_id: storeB.id, + name: '[SEED] 글레이즈드 도넛', + description: '갓 만든 글레이즈드 도넛 1개.', + regular_price: 3000, + is_active: true, + images: { + create: [ + { + image_url: 'https://placehold.co/600x600/png?text=Glazed+Donut', + sort_order: 0, + }, + ], + }, + }, + }); + + const p5 = await prisma.product.create({ + data: { + store_id: storeA.id, + name: '[SEED] (비활성) 단종 상품', + description: '판매 중단된 상품. 찜 가시성 정책 검증용.', + regular_price: 10000, + is_active: false, + }, + }); + + return { + stores: [storeA, storeB], + products: [p1, p2, p3, p4, p5], + optionGroupIds: { + p2GroupId: p2OptionGroup.id, + p2OptionItemId: p2RegularItem.id, + }, + }; +} diff --git a/prisma/seed/users.ts b/prisma/seed/users.ts new file mode 100644 index 0000000..fb4f834 --- /dev/null +++ b/prisma/seed/users.ts @@ -0,0 +1,58 @@ +/** + * 시드 유저 + 프로필. + * + * - user1: 온보딩 완료 — 마이페이지 API 전반 검증의 메인 계정 + * - user2: 온보딩 미완료 — me.needsProfile=true 검증용 + */ +import type { Account, PrismaClient } from '@prisma/client'; + +import { SEED_USER_EMAIL_PREFIX } from './idempotent'; + +export interface SeededUser extends Account { + profileId: bigint; +} + +export async function seedUsers(prisma: PrismaClient): Promise { + const now = new Date(); + + const user1 = await prisma.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: `${SEED_USER_EMAIL_PREFIX}1@dev.caquick`, + name: '테스트 유저 1', + }, + }); + const profile1 = await prisma.userProfile.create({ + data: { + account_id: user1.id, + nickname: 'seedTester1', + birth_date: new Date(Date.UTC(1995, 4, 15)), + phone_number: '010-1111-2222', + onboarding_completed_at: now, + }, + }); + + const user2 = await prisma.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: `${SEED_USER_EMAIL_PREFIX}2@dev.caquick`, + name: null, // 이름 없음 → 온보딩 미완료 + }, + }); + // 미완료 사용자도 UserProfile은 1:1 필수일 수 있음 — schema에 unique account_id라 row는 있어야 함 + const profile2 = await prisma.userProfile.create({ + data: { + account_id: user2.id, + // nickname은 unique 필수 → 임시 nickname 부여 + nickname: 'seedTester2-tmp', + onboarding_completed_at: null, + }, + }); + + return [ + { ...user1, profileId: profile1.id }, + { ...user2, profileId: profile2.id }, + ]; +} diff --git a/prisma/seed/wishlist.ts b/prisma/seed/wishlist.ts new file mode 100644 index 0000000..ca6a9b5 --- /dev/null +++ b/prisma/seed/wishlist.ts @@ -0,0 +1,30 @@ +/** + * 시드 찜 목록 (user1). + * + * - p1 (활성, recent-view에도 있음) → recent-view에서 isWishlisted=true + * - p3 (활성, recent-view에 없음) → myWishlist에는 보이지만 recent-view에는 안 나옴 + * - p5 (비활성 상품) → visibleWishlistWhere로 myWishlist/wishlistCount에서 제외 + * + * 결과: wishlistCount=2, myWishlist.totalCount=2 + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededStores } from './stores'; +import type { SeededUser } from './users'; + +export async function seedWishlist( + prisma: PrismaClient, + ctx: { users: SeededUser[]; stores: SeededStores }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedWishlist'); + const [p1, , p3, , p5] = ctx.stores.products; + + await prisma.wishlistItem.createMany({ + data: [ + { account_id: user1.id, product_id: p1.id }, + { account_id: user1.id, product_id: p3.id }, + { account_id: user1.id, product_id: p5.id }, + ], + }); +}