From 39cbe9fa52bce20e056b376cc9e6cbc33b03d14f Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 23:25:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor(prisma):=20soft-delete=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=ED=95=84=ED=84=B0=20=EA=B3=B5=EC=9A=A9=20where=20?= =?UTF-8?q?=EC=A1=B0=EA=B0=81=EC=9C=BC=EB=A1=9C=20=EC=A4=91=EC=95=99?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #207 단기 단계. extension이 루트 READ에만 deleted_at을 주입해 nested relation·relation 필터·mutation은 수동 명시에 의존했고, 반복 회귀의 원인이었다. 인라인 리터럴을 공용 조각으로 통일해 표기 흔들림과 누락 위험을 구조적으로 줄인다. - src/prisma/active-where.ts 신설: activeWhere({ deleted_at: null }) / visibleWhere({ is_active: true, deleted_at: null }). @/prisma 배럴 export. - 전수 조사(277라인) 기반 일괄 교체: · nested include/select where 54곳, relation 필터 46곳 → 조각 명시 · updateMany 루트 12곳 → 조각 명시(복원 조건 not: null 2곳은 리터럴 유지) · extension이 커버하는 루트 READ의 중복 명시 63곳 제거 · 공유 where 헬퍼(publicReviewWhere·visibleWishlistWhere 등)는 향후 nested 재사용 가능성이 있어 제거 대신 조각 조합으로 유지 - extension SOFT_DELETE_MODELS에 Region 추가 — deleted_at 보유 50개 모델 중 유일한 미등록(모델 추가 시 목록 갱신 누락). 루트 조회 4곳이 전부 수동 필터 중이라 동작 불변. - 재발 방지: dmmf 대조 테스트 신설 — deleted_at 보유 모델 ↔ 목록 diff 0 강제. - CLAUDE.md Prisma 섹션을 새 컨벤션(루트 READ는 extension 신뢰, 미커버 경로는 조각 명시) 기준으로 갱신. 회귀: 동작 변경 0 — 기존 spec 전체 무변경 green + dmmf 대조 2건 신규 (191 suites / 1,649 tests, yarn validate 통과). Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../auth/repositories/account.repository.ts | 4 +- .../order/repositories/order.repository.ts | 37 +++--- .../repositories/product-review.repository.ts | 36 +++--- .../repositories/product.repository.ts | 118 ++++++++---------- .../region/repositories/region.repository.ts | 10 +- .../repositories/store-review.repository.ts | 15 ++- .../repositories/store-wishlist.repository.ts | 13 +- .../store/repositories/store.repository.ts | 33 ++--- .../recent-product-view.repository.ts | 26 ++-- .../user/repositories/review.repository.ts | 18 +-- .../user/repositories/user.repository.ts | 37 +++--- src/prisma/active-where.ts | 18 +++ src/prisma/index.ts | 1 + src/prisma/soft-delete.middleware.spec.ts | 29 ++++- src/prisma/soft-delete.middleware.ts | 8 ++ 15 files changed, 206 insertions(+), 197 deletions(-) create mode 100644 src/prisma/active-where.ts diff --git a/src/features/auth/repositories/account.repository.ts b/src/features/auth/repositories/account.repository.ts index 255657a..6e318f7 100644 --- a/src/features/auth/repositories/account.repository.ts +++ b/src/features/auth/repositories/account.repository.ts @@ -11,7 +11,7 @@ import type { AccountWithProfile, IAccountRepository, } from '@/features/auth/repositories/account.repository.interface'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService } from '@/prisma'; /** * Account / AccountIdentity / UserProfile Repository 구체 구현. @@ -37,7 +37,7 @@ export class AccountRepository implements IAccountRepository { provider_subject: providerSubject, // soft-delete extension 은 top-level where 에만 deleted_at 을 주입한다. // 상위 엔티티(account)까지 활성인지는 직접 명시해야 탈퇴 계정이 새어나오지 않는다. - account: { deleted_at: null }, + account: activeWhere, }, include: { account: { diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 9629935..66b7007 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -9,7 +9,7 @@ import { type AccountType, } from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService } from '@/prisma'; export interface MyOrderRow { id: bigint; @@ -128,7 +128,7 @@ export class OrderRepository { } | null; } | null> { return this.prisma.account.findFirst({ - where: { id: accountId, deleted_at: null }, + where: { id: accountId }, select: { account_type: true, user_profile: { @@ -270,14 +270,14 @@ export class OrderRepository { take: args.limit, include: { items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, take: 1, include: { product: { select: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -310,7 +310,7 @@ export class OrderRepository { take: args.limit, include: { items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, take: 1, include: { @@ -320,7 +320,7 @@ export class OrderRepository { product: { select: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -330,7 +330,7 @@ export class OrderRepository { }, }, _count: { - select: { items: { where: { deleted_at: null } } }, + select: { items: { where: activeWhere } }, }, }, }); @@ -365,7 +365,6 @@ export class OrderRepository { const rows = await this.prisma.orderItem.findMany({ where: { order_id: { in: args.orderIds }, - deleted_at: null, order: { account_id: args.accountId, status: OrderStatus.PICKED_UP, @@ -393,13 +392,13 @@ export class OrderRepository { limit: number; }): Promise<{ items: ReviewableOrderItemRow[]; totalCount: number }> { const where = { - deleted_at: null, + ...activeWhere, order: { account_id: args.accountId, status: OrderStatus.PICKED_UP, // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 // 않으므로 삭제된 주문의 아이템이 노출되지 않게 명시한다 - deleted_at: null, + ...activeWhere, }, OR: [ { review: { is: null } }, @@ -421,7 +420,7 @@ export class OrderRepository { product: { select: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -452,11 +451,11 @@ export class OrderRepository { }, include: { status_histories: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { changed_at: 'asc' }, }, items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, include: { store: { @@ -473,7 +472,7 @@ export class OrderRepository { business_hours_text: true, website_url: true, business_hours: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { day_of_week: 'asc' }, }, }, @@ -481,7 +480,7 @@ export class OrderRepository { product: { select: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -489,19 +488,19 @@ export class OrderRepository { }, }, option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, }, custom_texts: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, free_edits: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, include: { attachments: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, diff --git a/src/features/product/repositories/product-review.repository.ts b/src/features/product/repositories/product-review.repository.ts index 660bf13..f09a92d 100644 --- a/src/features/product/repositories/product-review.repository.ts +++ b/src/features/product/repositories/product-review.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma, type ReviewMediaType } from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface ProductReviewMediaRow { media_type: ReviewMediaType; @@ -76,10 +76,10 @@ export class ProductReviewRepository { /** 공개 리뷰 공통 가드: 리뷰·상품·매장 모두 활성. */ private publicReviewWhere(photoOnly: boolean): Prisma.ReviewWhereInput { return { - deleted_at: null, - product: { is_active: true, deleted_at: null }, - store: { is_active: true, deleted_at: null }, - ...(photoOnly ? { media: { some: { deleted_at: null } } } : {}), + ...activeWhere, + product: visibleWhere, + store: visibleWhere, + ...(photoOnly ? { media: { some: activeWhere } } : {}), }; } @@ -214,7 +214,7 @@ export class ProductReviewRepository { > { if (reviewIds.length === 0) return []; return this.prisma.review.findMany({ - where: { id: { in: reviewIds }, deleted_at: null }, + where: { id: { in: reviewIds } }, select: { id: true, store_id: true, @@ -227,7 +227,7 @@ export class ProductReviewRepository { }, }, media: { - where: { deleted_at: null, media_type: 'IMAGE' }, + where: { ...activeWhere, media_type: 'IMAGE' }, orderBy: { sort_order: 'asc' }, take: 1, select: { media_url: true }, @@ -235,7 +235,7 @@ export class ProductReviewRepository { order_item: { select: { free_edits: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { crop_image_url: true }, @@ -265,7 +265,7 @@ export class ProductReviewRepository { ): Promise { if (reviewIds.length === 0) return []; return this.prisma.review.findMany({ - where: { id: { in: reviewIds }, deleted_at: null }, + where: { id: { in: reviewIds } }, select: { id: true, rating: true, @@ -285,7 +285,7 @@ export class ProductReviewRepository { }, }, media: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, select: { media_type: true, @@ -297,7 +297,7 @@ export class ProductReviewRepository { order_item: { select: { option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, select: { group_name_snapshot: true, @@ -333,7 +333,7 @@ export class ProductReviewRepository { }, }, media: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, select: { media_type: true, @@ -345,7 +345,7 @@ export class ProductReviewRepository { order_item: { select: { option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { id: 'asc' }, select: { group_name_snapshot: true, @@ -361,7 +361,7 @@ export class ProductReviewRepository { regular_price: true, sale_price: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -394,7 +394,7 @@ export class ProductReviewRepository { if (reviewIds.length === 0) return new Map(); const rows = await this.prisma.reviewLike.groupBy({ by: ['review_id'], - where: { review_id: { in: reviewIds }, deleted_at: null }, + where: { review_id: { in: reviewIds } }, _count: { _all: true }, }); return new Map(rows.map((r) => [r.review_id, r._count._all])); @@ -410,7 +410,6 @@ export class ProductReviewRepository { where: { review_id: { in: args.reviewIds }, account_id: args.accountId, - deleted_at: null, }, select: { review_id: true }, }); @@ -424,7 +423,7 @@ export class ProductReviewRepository { if (reviewIds.length === 0) return new Map(); const rows = await this.prisma.reviewComment.groupBy({ by: ['review_id'], - where: { review_id: { in: reviewIds }, deleted_at: null }, + where: { review_id: { in: reviewIds } }, _count: { _all: true }, }); return new Map(rows.map((r) => [r.review_id, r._count._all])); @@ -439,7 +438,6 @@ export class ProductReviewRepository { return this.prisma.reviewComment.findMany({ where: { review_id: args.reviewId, - deleted_at: null, ...(args.cursor !== undefined ? { id: { gt: args.cursor } } : {}), }, select: { @@ -467,7 +465,7 @@ export class ProductReviewRepository { /** 리뷰 활성 댓글 수. */ async countReviewComments(reviewId: bigint): Promise { return this.prisma.reviewComment.count({ - where: { review_id: reviewId, deleted_at: null }, + where: { review_id: reviewId }, }); } } diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index bd94260..2828079 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { type BannerLinkType, type CategoryType, Prisma } from '@prisma/client'; import { RANKING_VALID_ORDER_STATUSES } from '@/features/store'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; /** 구매자 매장 상품 카드 row. product-storefront 매퍼 입력. */ export interface StoreProductRow { @@ -139,7 +139,7 @@ export class ProductRepository { // soft-delete extension은 root만 patch하므로 nested relation에 가드를 명시한다 include: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, product_categories: { @@ -153,11 +153,11 @@ export class ProductRepository { }, }, option_groups: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, include: { option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -165,7 +165,7 @@ export class ProductRepository { custom_template: { include: { text_tokens: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -185,8 +185,7 @@ export class ProductRepository { where: { id: productId, is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + store: visibleWhere, }, select: { id: true }, }); @@ -203,7 +202,7 @@ export class ProductRepository { // soft-delete extension은 root만 patch하므로 nested relation에 가드를 명시한다 include: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, product_categories: { @@ -217,11 +216,11 @@ export class ProductRepository { }, }, option_groups: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, include: { option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -229,7 +228,7 @@ export class ProductRepository { custom_template: { include: { text_tokens: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -250,7 +249,7 @@ export class ProductRepository { // soft-delete extension은 root만 patch하므로 nested relation에 가드를 명시한다 include: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, product_categories: { @@ -264,11 +263,11 @@ export class ProductRepository { }, }, option_groups: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, include: { option_items: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -276,7 +275,7 @@ export class ProductRepository { custom_template: { include: { text_tokens: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -825,8 +824,7 @@ export class ProductRepository { where: { store_id: args.storeId, is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + store: visibleWhere, // 0n도 유효한 인자로 다뤄야 한다(parseId("0")=0n). truthiness 체크는 0n을 // falsy로 떨궈 잘못된 필터를 전체조회로 만들므로 undefined로만 분기한다. ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), @@ -835,8 +833,8 @@ export class ProductRepository { product_categories: { some: { category_id: args.categoryId, - deleted_at: null, - category: { is_active: true, deleted_at: null }, + ...activeWhere, + category: visibleWhere, }, }, } @@ -848,10 +846,10 @@ export class ProductRepository { { product_tags: { some: { - deleted_at: null, + ...activeWhere, tag: { name: { contains: args.search }, - deleted_at: null, + ...activeWhere, }, }, }, @@ -868,7 +866,7 @@ export class ProductRepository { sale_price: true, currency: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -876,8 +874,8 @@ export class ProductRepository { product_categories: { // storeProductCategories와 동일하게 비활성/삭제 카테고리는 categoryIds에서 제외 where: { - deleted_at: null, - category: { is_active: true, deleted_at: null }, + ...activeWhere, + category: visibleWhere, }, select: { category_id: true }, }, @@ -898,8 +896,7 @@ export class ProductRepository { where: { id: productId, is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + store: visibleWhere, }, select: { id: true, @@ -912,12 +909,12 @@ export class ProductRepository { currency: true, preparation_time_minutes: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, select: { image_url: true }, }, option_groups: { - where: { is_active: true, deleted_at: null }, + where: visibleWhere, orderBy: { sort_order: 'asc' }, select: { id: true, @@ -930,7 +927,7 @@ export class ProductRepository { option_requires_image: true, sort_order: true, option_items: { - where: { is_active: true, deleted_at: null }, + where: visibleWhere, orderBy: { sort_order: 'asc' }, select: { id: true, @@ -950,7 +947,7 @@ export class ProductRepository { /** 상품 활성 리뷰 수(후기 탭 카운트). */ async countProductReviews(productId: bigint): Promise { return this.prisma.review.count({ - where: { product_id: productId, deleted_at: null }, + where: { product_id: productId }, }); } @@ -963,7 +960,6 @@ export class ProductRepository { where: { account_id: args.accountId, product_id: args.productId, - deleted_at: null, }, select: { id: true }, }); @@ -981,10 +977,8 @@ export class ProductRepository { return this.prisma.product.findMany({ where: { is_active: true, - deleted_at: null, store: { - is_active: true, - deleted_at: null, + ...visibleWhere, ...(args.regionIds && args.regionIds.length > 0 ? { region_id: { in: args.regionIds } } : {}), @@ -995,11 +989,10 @@ export class ProductRepository { product_categories: { some: { category_id: args.categoryId, - deleted_at: null, + ...activeWhere, // 홈 칩은 EVENT 카테고리만 — STYLE/OTHER id가 오면 빈 결과로 처리 category: { - is_active: true, - deleted_at: null, + ...visibleWhere, category_type: 'EVENT', }, }, @@ -1014,7 +1007,7 @@ export class ProductRepository { regular_price: true, sale_price: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -1038,7 +1031,7 @@ export class ProductRepository { if (productIds.length === 0) return new Map(); const rows = await this.prisma.wishlistItem.groupBy({ by: ['product_id'], - where: { product_id: { in: productIds }, deleted_at: null }, + where: { product_id: { in: productIds } }, _count: { _all: true }, }); return new Map(rows.map((r) => [r.product_id, r._count._all])); @@ -1051,7 +1044,7 @@ export class ProductRepository { if (productIds.length === 0) return new Map(); const rows = await this.prisma.review.groupBy({ by: ['product_id'], - where: { product_id: { in: productIds }, deleted_at: null }, + where: { product_id: { in: productIds } }, _avg: { rating: true }, _count: { _all: true }, }); @@ -1076,13 +1069,12 @@ export class ProductRepository { by: ['product_id'], where: { product_id: { in: productIds }, - deleted_at: null, order: { status: { in: [...RANKING_VALID_ORDER_STATUSES] }, created_at: { gte: since }, // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 // 않으므로(=root read만 보정), 삭제된 주문이 랭킹을 부풀리지 않도록 명시한다. - deleted_at: null, + ...activeWhere, }, }, _count: { _all: true }, @@ -1096,7 +1088,6 @@ export class ProductRepository { */ async globalReviewAverage(): Promise { const agg = await this.prisma.review.aggregate({ - where: { deleted_at: null }, _avg: { rating: true }, }); return agg._avg.rating !== null ? Number(agg._avg.rating) : null; @@ -1114,15 +1105,13 @@ export class ProductRepository { return this.prisma.banner.findFirst({ where: { is_active: true, - deleted_at: null, ...(args.categoryId !== undefined ? { placement: 'CATEGORY', link_category_id: args.categoryId, // 랭킹과 동일하게 홈 칩은 EVENT 카테고리만 — 비EVENT id면 배너도 없음 link_category: { - is_active: true, - deleted_at: null, + ...visibleWhere, category_type: 'EVENT', }, } @@ -1138,18 +1127,17 @@ export class ProductRepository { { link_type: 'PRODUCT', link_product: { - is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + ...visibleWhere, + store: visibleWhere, }, }, { link_type: 'STORE', - link_store: { is_active: true, deleted_at: null }, + link_store: visibleWhere, }, { link_type: 'CATEGORY', - link_category: { is_active: true, deleted_at: null }, + link_category: visibleWhere, }, ], }, @@ -1178,20 +1166,18 @@ export class ProductRepository { const rows = await this.prisma.product.findMany({ where: { is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, - images: { some: { deleted_at: null } }, + store: visibleWhere, + images: { some: activeWhere }, // 0n도 유효한 인자(parseId("0")=0n) → undefined로만 분기한다. ...(categoryId !== undefined ? { product_categories: { some: { category_id: categoryId, - deleted_at: null, + ...activeWhere, // 홈 칩은 EVENT 카테고리만 — 랭킹(findActiveCakesForRanking)과 동일 정책 category: { - is_active: true, - deleted_at: null, + ...visibleWhere, category_type: 'EVENT', }, }, @@ -1219,17 +1205,15 @@ export class ProductRepository { id: { in: args.productIds }, // 후보 추출과 재조회 사이에 비활성화·카테고리 해제된 상품이 노출되지 않게 재검증 is_active: true, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + store: visibleWhere, ...(args.categoryId !== undefined ? { product_categories: { some: { category_id: args.categoryId, - deleted_at: null, + ...activeWhere, category: { - is_active: true, - deleted_at: null, + ...visibleWhere, category_type: 'EVENT', }, }, @@ -1241,7 +1225,7 @@ export class ProductRepository { id: true, store_id: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -1258,7 +1242,6 @@ export class ProductRepository { return this.prisma.category.findMany({ where: { is_active: true, - deleted_at: null, ...(type !== undefined ? { category_type: type } : {}), }, select: { @@ -1281,13 +1264,11 @@ export class ProductRepository { const grouped = await this.prisma.productCategory.groupBy({ by: ['category_id'], where: { - deleted_at: null, product: { store_id: storeId, - is_active: true, - deleted_at: null, + ...visibleWhere, // storeProducts와 동일하게 비활성/삭제 매장은 카테고리도 노출하지 않는다 - store: { is_active: true, deleted_at: null }, + store: visibleWhere, }, }, _count: { _all: true }, @@ -1301,7 +1282,6 @@ export class ProductRepository { where: { id: { in: grouped.map((g) => g.category_id) }, is_active: true, - deleted_at: null, }, select: { id: true, diff --git a/src/features/region/repositories/region.repository.ts b/src/features/region/repositories/region.repository.ts index 5d20ff3..987846b 100644 --- a/src/features/region/repositories/region.repository.ts +++ b/src/features/region/repositories/region.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '@/prisma'; +import { PrismaService, visibleWhere } from '@/prisma'; export interface RegionRow { id: bigint; @@ -31,14 +31,14 @@ export class RegionRepository { /** 1차 광역 지역 목록. hasChildren 판정을 위해 활성 2차를 1건만 동반 조회. */ async findActiveGroups(): Promise { return this.prisma.region.findMany({ - where: { level: 1, is_active: true, deleted_at: null }, + where: { level: 1, is_active: true }, orderBy: { sort_order: 'asc' }, select: { id: true, name: true, slug: true, children: { - where: { is_active: true, deleted_at: null }, + where: visibleWhere, select: { id: true }, take: 1, }, @@ -53,7 +53,6 @@ export class RegionRepository { parent_id: parentId, level: 2, is_active: true, - deleted_at: null, }, orderBy: { sort_order: 'asc' }, select: { @@ -69,7 +68,7 @@ export class RegionRepository { /** parentId 유효성 검증용. 활성 1차 지역 존재 여부. */ async existsActiveGroup(id: bigint): Promise { const found = await this.prisma.region.findFirst({ - where: { id, level: 1, is_active: true, deleted_at: null }, + where: { id, level: 1, is_active: true }, select: { id: true }, }); return Boolean(found); @@ -84,7 +83,6 @@ export class RegionRepository { where: { name: { contains: keyword }, is_active: true, - deleted_at: null, }, orderBy: [{ level: 'asc' }, { sort_order: 'asc' }], take: limit, diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts index 2bdc023..a83f75c 100644 --- a/src/features/store/repositories/store-review.repository.ts +++ b/src/features/store/repositories/store-review.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma, type ReviewMediaType } from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface StoreReviewMediaRow { media_type: ReviewMediaType; @@ -36,10 +36,10 @@ export class StoreReviewRepository { /** 매장 공개 리뷰 공통 가드: 리뷰·매장 활성(photoOnly면 활성 미디어 존재). */ private publicReviewWhere(photoOnly: boolean): Prisma.ReviewWhereInput { return { - deleted_at: null, + ...activeWhere, // storeDetail과 동일하게 비활성/삭제 매장의 리뷰는 노출하지 않는다 - store: { is_active: true, deleted_at: null }, - ...(photoOnly ? { media: { some: { deleted_at: null } } } : {}), + store: visibleWhere, + ...(photoOnly ? { media: { some: activeWhere } } : {}), }; } @@ -119,7 +119,7 @@ export class StoreReviewRepository { ): Promise { if (reviewIds.length === 0) return []; return this.prisma.review.findMany({ - where: { id: { in: reviewIds }, deleted_at: null }, + where: { id: { in: reviewIds } }, select: { id: true, rating: true, @@ -134,7 +134,7 @@ export class StoreReviewRepository { }, order_item: { select: { product_name_snapshot: true } }, media: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, select: { media_type: true, @@ -165,7 +165,7 @@ export class StoreReviewRepository { if (reviewIds.length === 0) return new Map(); const rows = await this.prisma.reviewLike.groupBy({ by: ['review_id'], - where: { review_id: { in: reviewIds }, deleted_at: null }, + where: { review_id: { in: reviewIds } }, _count: { _all: true }, }); return new Map(rows.map((r) => [r.review_id, r._count._all])); @@ -181,7 +181,6 @@ export class StoreReviewRepository { where: { review_id: { in: args.reviewIds }, account_id: args.accountId, - deleted_at: null, }, select: { review_id: true }, }); diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index ce871ec..aaa4d9c 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; /** 찜한 매장 목록 조회 결과 row. myWishlistedStores 매퍼 입력. */ export interface WishlistedStoreRow { @@ -67,7 +67,7 @@ export class StoreWishlistRepository { where: { account_id: args.accountId, store_id: args.storeId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -86,8 +86,7 @@ export class StoreWishlistRepository { where: { account_id: args.accountId, store_id: { in: args.storeIds }, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + store: visibleWhere, }, select: { store_id: true }, }); @@ -106,8 +105,8 @@ export class StoreWishlistRepository { }): Promise<{ items: WishlistedStoreRow[]; totalCount: number }> { const where = { account_id: args.accountId, - deleted_at: null, - store: { is_active: true, deleted_at: null }, + ...activeWhere, + store: visibleWhere, }; const [items, totalCount] = await this.prisma.$transaction([ @@ -140,7 +139,7 @@ export class StoreWishlistRepository { /** 활성 USER 계정 여부. 매장 찜은 구매자(USER)만 가능 → 인기 랭킹 무결성 보호. */ async isActiveUserAccount(accountId: bigint): Promise { const account = await this.prisma.account.findFirst({ - where: { id: accountId, account_type: 'USER', deleted_at: null }, + where: { id: accountId, account_type: 'USER' }, select: { id: true }, }); return Boolean(account); diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 541cccd..c378466 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -5,7 +5,7 @@ import { POPULAR_STORE_CAKE_IMAGE_LIMIT, RANKING_VALID_ORDER_STATUSES, } from '@/features/store/constants/store-ranking.constants'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface StoreCandidateRow { id: bigint; @@ -77,7 +77,6 @@ export class StoreRepository { return this.prisma.store.findMany({ where: { is_active: true, - deleted_at: null, ...(regionIds && regionIds.length > 0 ? { region_id: { in: regionIds } } : {}), @@ -105,7 +104,6 @@ export class StoreRepository { where: { store_id: { in: storeIds }, day_of_week: dayOfWeek, - deleted_at: null, }, select: { store_id: true, @@ -126,7 +124,6 @@ export class StoreRepository { where: { store_id: { in: storeIds }, closure_date: date, - deleted_at: null, }, select: { store_id: true }, }); @@ -143,7 +140,6 @@ export class StoreRepository { where: { store_id: { in: storeIds }, capacity_date: date, - deleted_at: null, }, select: { store_id: true, capacity: true }, }); @@ -184,7 +180,7 @@ export class StoreRepository { storeId: bigint, ): Promise { return this.prisma.store.findFirst({ - where: { id: storeId, is_active: true, deleted_at: null }, + where: { id: storeId, is_active: true }, select: { id: true, pickup_slot_interval_minutes: true, @@ -199,7 +195,7 @@ export class StoreRepository { storeId: bigint, ): Promise { return this.prisma.storeBusinessHour.findMany({ - where: { store_id: storeId, deleted_at: null }, + where: { store_id: storeId }, select: { day_of_week: true, is_closed: true, @@ -219,7 +215,6 @@ export class StoreRepository { where: { store_id: storeId, closure_date: { gte: from, lt: to }, - deleted_at: null, }, select: { closure_date: true }, }); @@ -236,7 +231,6 @@ export class StoreRepository { where: { store_id: storeId, capacity_date: { gte: from, lt: to }, - deleted_at: null, }, select: { capacity_date: true, capacity: true }, }); @@ -277,7 +271,7 @@ export class StoreRepository { /** 활성 매장 존재 검증(찜 등). */ async existsActiveStore(storeId: bigint): Promise { const found = await this.prisma.store.findFirst({ - where: { id: storeId, is_active: true, deleted_at: null }, + where: { id: storeId, is_active: true }, select: { id: true }, }); return Boolean(found); @@ -286,7 +280,7 @@ export class StoreRepository { /** 매장 상세 헤더 조회. 활성·미삭제 매장만. 대표 이미지는 sort_order asc. */ async findStoreDetailById(storeId: bigint): Promise { return this.prisma.store.findFirst({ - where: { id: storeId, is_active: true, deleted_at: null }, + where: { id: storeId, is_active: true }, select: { id: true, store_name: true, @@ -303,7 +297,7 @@ export class StoreRepository { website_url: true, region: { select: { name: true } }, store_images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, select: { image_url: true }, }, @@ -318,7 +312,7 @@ export class StoreRepository { if (storeIds.length === 0) return new Map(); const rows = await this.prisma.storeWishlistItem.groupBy({ by: ['store_id'], - where: { store_id: { in: storeIds }, deleted_at: null }, + where: { store_id: { in: storeIds } }, _count: { _all: true }, }); return new Map(rows.map((r) => [r.store_id, r._count._all])); @@ -331,7 +325,7 @@ export class StoreRepository { if (storeIds.length === 0) return new Map(); const rows = await this.prisma.review.groupBy({ by: ['store_id'], - where: { store_id: { in: storeIds }, deleted_at: null }, + where: { store_id: { in: storeIds } }, _avg: { rating: true }, _count: { _all: true }, }); @@ -356,13 +350,12 @@ export class StoreRepository { by: ['store_id'], where: { store_id: { in: storeIds }, - deleted_at: null, order: { status: { in: [...RANKING_VALID_ORDER_STATUSES] }, created_at: { gte: since }, // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 // 않으므로(=root read만 보정), 삭제된 주문이 랭킹을 부풀리지 않도록 명시한다. - deleted_at: null, + ...activeWhere, }, }, _count: { _all: true }, @@ -373,7 +366,6 @@ export class StoreRepository { /** 전체 활성 리뷰 평균 평점(베이지안 prior). 리뷰가 없으면 null. */ async globalReviewAverage(): Promise { const agg = await this.prisma.review.aggregate({ - where: { deleted_at: null }, _avg: { rating: true }, }); return agg._avg.rating !== null ? Number(agg._avg.rating) : null; @@ -394,15 +386,14 @@ export class StoreRepository { const products = await this.prisma.product.findMany({ where: { store_id: storeId, - is_active: true, - deleted_at: null, - images: { some: { deleted_at: null } }, + ...visibleWhere, + images: { some: activeWhere }, }, orderBy: { id: 'desc' }, take: limit, select: { images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, diff --git a/src/features/user/repositories/recent-product-view.repository.ts b/src/features/user/repositories/recent-product-view.repository.ts index c967621..25a32be 100644 --- a/src/features/user/repositories/recent-product-view.repository.ts +++ b/src/features/user/repositories/recent-product-view.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface RecentViewedProductRow { product_id: bigint; @@ -29,11 +29,10 @@ export class RecentProductViewRepository { }): Promise<{ items: RecentViewedProductRow[]; totalCount: number }> { const where = { account_id: args.accountId, - deleted_at: null, + ...activeWhere, product: { - deleted_at: null, - is_active: true, - store: { deleted_at: null }, + ...visibleWhere, + store: activeWhere, }, }; @@ -47,7 +46,7 @@ export class RecentProductViewRepository { sale_price: true as const, store: { select: { store_name: true as const } }, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' as const }, take: 1, select: { image_url: true as const }, @@ -96,7 +95,7 @@ export class RecentProductViewRepository { async countByAccount(accountId: bigint): Promise { return this.prisma.recentProductView.count({ - where: { account_id: accountId, deleted_at: null }, + where: { account_id: accountId }, }); } @@ -106,7 +105,7 @@ export class RecentProductViewRepository { now: Date; }): Promise { const oldest = await this.prisma.recentProductView.findMany({ - where: { account_id: args.accountId, deleted_at: null }, + where: { account_id: args.accountId }, orderBy: { viewed_at: 'desc' }, skip: args.maxCount, select: { id: true }, @@ -129,7 +128,7 @@ export class RecentProductViewRepository { where: { account_id: args.accountId, product_id: args.productId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -143,7 +142,7 @@ export class RecentProductViewRepository { const result = await this.prisma.recentProductView.updateMany({ where: { account_id: args.accountId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -158,9 +157,8 @@ export class RecentProductViewRepository { where: { account_id: accountId, product: { - deleted_at: null, - is_active: true, - store: { deleted_at: null }, + ...visibleWhere, + store: activeWhere, }, }, orderBy: { viewed_at: 'desc' }, @@ -177,7 +175,7 @@ export class RecentProductViewRepository { select: { store_name: true }, }, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, diff --git a/src/features/user/repositories/review.repository.ts b/src/features/user/repositories/review.repository.ts index d16e79d..457a581 100644 --- a/src/features/user/repositories/review.repository.ts +++ b/src/features/user/repositories/review.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import type { ReviewMediaType } from '@prisma/client'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService } from '@/prisma'; @Injectable() export class ReviewRepository { @@ -25,7 +25,7 @@ export class ReviewRepository { id: true, name: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -118,7 +118,7 @@ export class ReviewRepository { select: { id: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -128,7 +128,7 @@ export class ReviewRepository { }, }, media: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -158,7 +158,7 @@ export class ReviewRepository { select: { id: true, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -168,7 +168,7 @@ export class ReviewRepository { }, }, media: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -189,7 +189,7 @@ export class ReviewRepository { where: { id: args.reviewId, account_id: args.accountId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -198,7 +198,7 @@ export class ReviewRepository { await tx.reviewMedia.updateMany({ where: { review_id: args.reviewId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -207,7 +207,7 @@ export class ReviewRepository { await tx.reviewComment.updateMany({ where: { review_id: args.reviewId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 91d98a2..f42f6a5 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -9,7 +9,7 @@ import { } from '@prisma/client'; import { buildWithdrawnProviderSubject } from '@/common/utils/withdrawn-identity'; -import { PrismaService } from '@/prisma'; +import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface UserAccountIdentity { provider: IdentityProvider; @@ -37,12 +37,6 @@ export interface UserAccountWithProfile { export class UserRepository { constructor(private readonly prisma: PrismaService) {} - private activeRelationWhere>( - where: T, - ): T & { deleted_at: null } { - return { ...where, deleted_at: null }; - } - /** * 화면에 노출 가능한 wishlist row 조건. * - wishlist 자체가 active (deleted_at: null) @@ -55,13 +49,12 @@ export class UserRepository { private visibleWishlistWhere(accountId: bigint, storeId?: bigint) { return { account_id: accountId, - deleted_at: null, + ...activeWhere, product: { - deleted_at: null, - is_active: true, + ...visibleWhere, // 매장별 보기 → 매장 선택 화면의 매장 필터 ...(storeId !== undefined ? { store_id: storeId } : {}), - store: { deleted_at: null, is_active: true }, + store: visibleWhere, }, } as const; } @@ -80,7 +73,7 @@ export class UserRepository { // soft-deleted identity는 노출 대상 아님. 최근 로그인 순으로 정렬해 // FE가 "최근 로그인 provider" 표시할 때 별도 정렬 없이 사용 가능. account_identities: { - where: { deleted_at: null }, + where: activeWhere, orderBy: [{ last_login_at: 'desc' }, { id: 'asc' }], select: { provider: true, last_login_at: true }, }, @@ -207,7 +200,7 @@ export class UserRepository { where: { account_id: args.accountId, revoked_at: null, - deleted_at: null, + ...activeWhere, }, data: { revoked_at: args.now, @@ -233,7 +226,7 @@ export class UserRepository { now: Date, ): Promise { const identities = await tx.accountIdentity.findMany({ - where: { account_id: accountId, deleted_at: null }, + where: { account_id: accountId }, select: { id: true, provider_subject: true }, }); @@ -266,7 +259,7 @@ export class UserRepository { }), this.prisma.cartItem.count({ where: { - cart: this.activeRelationWhere({ account_id: accountId }), + cart: { account_id: accountId, ...activeWhere }, }, }), this.prisma.wishlistItem.count({ @@ -350,7 +343,7 @@ export class UserRepository { const result = await this.prisma.notification.updateMany({ where: { account_id: args.accountId, - deleted_at: null, + ...activeWhere, read_at: null, }, data: { read_at: args.now }, @@ -402,7 +395,7 @@ export class UserRepository { where: { id: args.id, account_id: args.accountId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -416,7 +409,7 @@ export class UserRepository { const result = await this.prisma.searchHistory.updateMany({ where: { account_id: args.accountId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -495,7 +488,7 @@ export class UserRepository { where: { account_id: args.accountId, product_id: args.productId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: args.now }, }); @@ -577,7 +570,7 @@ export class UserRepository { }, }, images: { - where: { deleted_at: null }, + where: activeWhere, orderBy: { sort_order: 'asc' }, take: 1, select: { image_url: true }, @@ -718,7 +711,7 @@ export class UserRepository { where: { review_id: args.reviewId, account_id: args.accountId, - deleted_at: null, + ...activeWhere, }, data: { deleted_at: new Date() }, }); @@ -773,7 +766,7 @@ export class UserRepository { }): Promise<'deleted' | 'not-found' | 'forbidden'> { const comment = await this.prisma.reviewComment.findFirst({ // extension이 주입하지만 재삭제 방지 계약을 코드에서 바로 읽도록 명시한다 - where: { id: args.commentId, deleted_at: null }, + where: { id: args.commentId, ...activeWhere }, select: { id: true, account_id: true }, }); if (!comment) return 'not-found'; diff --git a/src/prisma/active-where.ts b/src/prisma/active-where.ts new file mode 100644 index 0000000..c07c23a --- /dev/null +++ b/src/prisma/active-where.ts @@ -0,0 +1,18 @@ +/** + * soft-delete 활성 필터 공용 where 조각 (이슈 #207). + * + * soft-delete extension은 "루트 READ 쿼리"에만 `deleted_at: null`을 자동 + * 주입한다 — nested relation(include/select 내부)·relation 필터(some/is 등)· + * mutation(updateMany 등)·raw SQL에는 닿지 않는다. 그 미커버 경로에서 + * 인라인 리터럴 대신 이 조각을 조합해, 필터 누락·표기 흔들림을 한곳에서 + * 통제한다. + * + * 컨벤션: 루트 READ는 extension을 신뢰해 명시하지 않고, nested/relation + * 필터/mutation은 반드시 이 조각을 명시한다. + */ + +/** soft-delete 활성(삭제되지 않음). */ +export const activeWhere = { deleted_at: null } as const; + +/** 노출 활성 — is_active 플래그를 함께 갖는 모델(Store·Product·Category 등)용. */ +export const visibleWhere = { is_active: true, deleted_at: null } as const; diff --git a/src/prisma/index.ts b/src/prisma/index.ts index 20b3d11..e5e6bdb 100644 --- a/src/prisma/index.ts +++ b/src/prisma/index.ts @@ -1,2 +1,3 @@ export { PrismaService } from '@/prisma/prisma.service'; export { PrismaModule } from '@/prisma/prisma.module'; +export { activeWhere, visibleWhere } from '@/prisma/active-where'; diff --git a/src/prisma/soft-delete.middleware.spec.ts b/src/prisma/soft-delete.middleware.spec.ts index 9d519f1..2c40d7e 100644 --- a/src/prisma/soft-delete.middleware.spec.ts +++ b/src/prisma/soft-delete.middleware.spec.ts @@ -1,6 +1,9 @@ import { Prisma } from '@prisma/client'; -import { applySoftDeleteArgs } from '@/prisma/soft-delete.middleware'; +import { + applySoftDeleteArgs, + SOFT_DELETE_MODEL_NAMES, +} from '@/prisma/soft-delete.middleware'; type SoftDeleteInput = { model?: Prisma.ModelName; @@ -194,3 +197,27 @@ describe('soft delete extension', () => { }); }); }); + +// 모델 추가 시 SOFT_DELETE_MODELS 갱신 누락(Region 사례, 이슈 #207)을 구조로 차단한다. +describe('SOFT_DELETE_MODELS 커버리지 (dmmf 대조)', () => { + const modelsWithDeletedAt = Prisma.dmmf.datamodel.models + .filter((model) => + model.fields.some((field) => field.name === 'deleted_at'), + ) + .map((model) => model.name); + + it('deleted_at 컬럼을 가진 모든 모델이 목록에 등록되어 있다', () => { + const missing = modelsWithDeletedAt.filter( + (name) => !SOFT_DELETE_MODEL_NAMES.has(name as Prisma.ModelName), + ); + expect(missing).toEqual([]); + }); + + it('목록에 deleted_at 없는 모델이 섞여 있지 않다', () => { + const withDeletedAt = new Set(modelsWithDeletedAt); + const extras = [...SOFT_DELETE_MODEL_NAMES].filter( + (name) => !withDeletedAt.has(name), + ); + expect(extras).toEqual([]); + }); +}); diff --git a/src/prisma/soft-delete.middleware.ts b/src/prisma/soft-delete.middleware.ts index aea626b..e2f6e44 100644 --- a/src/prisma/soft-delete.middleware.ts +++ b/src/prisma/soft-delete.middleware.ts @@ -1,5 +1,8 @@ import { Prisma } from '@prisma/client'; +// deleted_at 컬럼을 가진 모든 모델이 등록되어야 한다 — 스키마와의 일치는 +// soft-delete.middleware.spec.ts의 dmmf 대조 테스트가 강제한다. +// (Region은 모델 추가 시 이 목록 갱신이 누락됐던 사례 — 이슈 #207에서 보강) const SOFT_DELETE_MODELS = new Set([ 'Account', 'UserProfile', @@ -50,8 +53,13 @@ const SOFT_DELETE_MODELS = new Set([ 'StoreFaqTopic', 'StoreDailyCapacity', 'RecentProductView', + 'Region', ]); +/** dmmf 대조 테스트 전용 — 런타임 소비 금지. */ +export const SOFT_DELETE_MODEL_NAMES: ReadonlySet = + SOFT_DELETE_MODELS; + const READ_ACTIONS = new Set([ 'findFirst', 'findFirstOrThrow',