diff --git a/src/features/product/product-admin-banner.graphql b/src/features/product/product-admin-banner.graphql index 718d15a4..2fe27d51 100644 --- a/src/features/product/product-admin-banner.graphql +++ b/src/features/product/product-admin-banner.graphql @@ -69,6 +69,11 @@ type AdminBanner { """ linkCategoryId: ID """ + 링크 대상이 지금 노출 가능한지. 구매자 앱과 같은 기준(상품·매장·카테고리가 활성·미삭제, 상품은 소속 매장까지)이며, + false면 구매자 앱이 이 배너를 건너뛰고 다음 배너를 노출한다. linkType이 NONE·URL이면 항상 true. + """ + linkTargetAvailable: Boolean! + """ 노출 시작 일시. null이면 시작 제한 없음. """ startsAt: DateTime diff --git a/src/features/product/repositories/banner-link-target.helper.ts b/src/features/product/repositories/banner-link-target.helper.ts new file mode 100644 index 00000000..0be1d4d0 --- /dev/null +++ b/src/features/product/repositories/banner-link-target.helper.ts @@ -0,0 +1,18 @@ +import type { Prisma } from '@/generated/prisma/client'; +import { visibleWhere } from '@/prisma'; + +/** + * 링크 대상이 노출 가능한 배너. 구매자 배너 선택과 관리자 linkTargetAvailable이 이 조각 하나를 쓴다 — + * 대상이 내려간 배너를 노출하면 클릭이 죽은 화면으로 떨어지므로 구매자 조회는 다음 배너로 넘어간다. + */ +export const bannerLinkTargetVisibleWhere: Prisma.BannerWhereInput = { + OR: [ + { link_type: { in: ['NONE', 'URL'] } }, + { + link_type: 'PRODUCT', + link_product: { ...visibleWhere, store: visibleWhere }, + }, + { link_type: 'STORE', link_store: visibleWhere }, + { link_type: 'CATEGORY', link_category: visibleWhere }, + ], +}; diff --git a/src/features/product/repositories/product-admin.repository.ts b/src/features/product/repositories/product-admin.repository.ts index a2efc8fa..21834e84 100644 --- a/src/features/product/repositories/product-admin.repository.ts +++ b/src/features/product/repositories/product-admin.repository.ts @@ -7,6 +7,7 @@ import { type AuditEntry, type IAuditLogRepository, } from '@/features/audit-log'; +import { bannerLinkTargetVisibleWhere } from '@/features/product/repositories/banner-link-target.helper'; import { type Banner, type BannerPlacement, @@ -185,6 +186,18 @@ export class ProductAdminRepository { return this.prisma.banner.findFirst({ where: { id: bannerId } }); } + /** 구매자 배너 선택과 같은 조각으로 거른다 — 규칙을 TS로 다시 쓰면 둘이 어긋난다. */ + async findLinkTargetVisibleBannerIds( + bannerIds: bigint[], + ): Promise> { + if (bannerIds.length === 0) return new Set(); + const rows = await this.prisma.banner.findMany({ + where: { id: { in: bannerIds }, AND: [bannerLinkTargetVisibleWhere] }, + select: { id: true }, + }); + return new Set(rows.map((row) => row.id)); + } + // 조작과 감사 기록을 한 트랜잭션으로 — 조작만 커밋되고 기록이 빠지는 상태를 막는다. async createBanner( diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 43a7df09..407a5b0b 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -5,6 +5,7 @@ import { type AuditEntry, type IAuditLogRepository, } from '@/features/audit-log'; +import { bannerLinkTargetVisibleWhere } from '@/features/product/repositories/banner-link-target.helper'; import { type BannerLinkType, type CategoryType, @@ -1361,28 +1362,7 @@ export class ProductRepository { OR: [{ starts_at: null }, { starts_at: { lte: now } }], AND: [ { OR: [{ ends_at: null }, { ends_at: { gt: now } }] }, - { - // 링크 대상이 내려간(비활성/삭제) 배너를 노출하면 클릭이 죽은 화면으로 - // 떨어지므로 대상 활성까지 확인하고 다음 배너로 넘어간다 - OR: [ - { link_type: { in: ['NONE', 'URL'] } }, - { - link_type: 'PRODUCT', - link_product: { - ...visibleWhere, - store: visibleWhere, - }, - }, - { - link_type: 'STORE', - link_store: visibleWhere, - }, - { - link_type: 'CATEGORY', - link_category: visibleWhere, - }, - ], - }, + bannerLinkTargetVisibleWhere, ], }, select: { diff --git a/src/features/product/services/product-admin-banner-mappers.helper.ts b/src/features/product/services/product-admin-banner-mappers.helper.ts index c2c5be1f..b2cbe37b 100644 --- a/src/features/product/services/product-admin-banner-mappers.helper.ts +++ b/src/features/product/services/product-admin-banner-mappers.helper.ts @@ -2,7 +2,10 @@ import type { AdminBannerOutput } from '@/features/product/types/product-admin-o import type { Banner } from '@/generated/prisma/client'; /** 저장된 링크 값을 그대로 내린다 — 표시·이동 판단은 linkType 기준. */ -export function toAdminBannerOutput(row: Banner): AdminBannerOutput { +export function toAdminBannerOutput( + row: Banner, + linkTargetAvailable: boolean, +): AdminBannerOutput { return { id: row.id.toString(), placement: row.placement, @@ -13,6 +16,7 @@ export function toAdminBannerOutput(row: Banner): AdminBannerOutput { linkProductId: row.link_product_id?.toString() ?? null, linkStoreId: row.link_store_id?.toString() ?? null, linkCategoryId: row.link_category_id?.toString() ?? null, + linkTargetAvailable, startsAt: row.starts_at, endsAt: row.ends_at, sortOrder: row.sort_order, diff --git a/src/features/product/services/product-admin-banner.service.spec.ts b/src/features/product/services/product-admin-banner.service.spec.ts index 222d022f..4b6b4431 100644 --- a/src/features/product/services/product-admin-banner.service.spec.ts +++ b/src/features/product/services/product-admin-banner.service.spec.ts @@ -2,6 +2,7 @@ import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; import { ProductAdminRepository } from '@/features/product/repositories/product-admin.repository'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; import { AdminBannerService } from '@/features/product/services/product-admin-banner.service'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -21,6 +22,7 @@ import { describe('AdminBannerService (real DB)', () => { let service: AdminBannerService; + let buyerRepo: ProductRepository; let prisma: PrismaClient; beforeAll(async () => { @@ -29,11 +31,13 @@ describe('AdminBannerService (real DB)', () => { ...s3TestProviders(), AdminBannerService, ProductAdminRepository, + ProductRepository, AccountAdminRepository, { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, ], }); service = module.get(AdminBannerService); + buyerRepo = module.get(ProductRepository); prisma = p; }); @@ -60,8 +64,11 @@ describe('AdminBannerService (real DB)', () => { placement: 'HOME_MAIN' | 'HOME_SUB' | 'CATEGORY' | 'STORE' | 'SEARCH'; is_active: boolean; link_type: 'NONE' | 'URL' | 'PRODUCT' | 'STORE' | 'CATEGORY'; + link_product_id: bigint | null; link_store_id: bigint | null; + link_category_id: bigint | null; link_url: string | null; + sort_order: number; }> = {}, ) { return prisma.banner.create({ @@ -69,8 +76,11 @@ describe('AdminBannerService (real DB)', () => { placement: overrides.placement ?? 'HOME_MAIN', image_url: 'https://i.example/b.png', link_type: overrides.link_type ?? 'NONE', + link_product_id: overrides.link_product_id ?? null, link_store_id: overrides.link_store_id ?? null, + link_category_id: overrides.link_category_id ?? null, link_url: overrides.link_url ?? null, + sort_order: overrides.sort_order ?? 0, is_active: overrides.is_active ?? true, }, }); @@ -650,6 +660,117 @@ describe('AdminBannerService (real DB)', () => { }); }); + describe('linkTargetAvailable', () => { + // 저장 뒤 대상이 내려간 상황을 직접 만든다 — 저장 시점 검증은 숨겨진 대상을 받지 않는다 + type TargetState = { is_active?: boolean; deleted_at?: Date }; + const inactive: TargetState = { is_active: false }; + const deleted: TargetState = { deleted_at: new Date() }; + const productLink = async ( + own: TargetState = {}, + store: TargetState = {}, + ) => ({ + link_type: 'PRODUCT' as const, + link_product_id: ( + await createProduct(prisma, { + ...own, + store_id: (await createStore(prisma, store)).id, + }) + ).id, + }); + const storeLink = async (own: TargetState = {}) => ({ + link_type: 'STORE' as const, + link_store_id: (await createStore(prisma, own)).id, + }); + const categoryLink = async (own: TargetState = {}) => ({ + link_type: 'CATEGORY' as const, + link_category_id: (await createCategory(prisma, own)).id, + }); + + it.each([ + ['NONE', true, () => Promise.resolve({ link_type: 'NONE' as const })], + [ + 'URL', + true, + () => + Promise.resolve({ + link_type: 'URL' as const, + link_url: 'https://caquick.example/e', + }), + ], + ['노출 중인 상품', true, () => productLink()], + ['비활성 상품', false, () => productLink(inactive)], + ['삭제된 상품', false, () => productLink(deleted)], + ['비활성 매장의 상품', false, () => productLink({}, inactive)], + ['삭제된 매장의 상품', false, () => productLink({}, deleted)], + ['노출 중인 매장', true, () => storeLink()], + ['비활성 매장', false, () => storeLink(inactive)], + ['삭제된 매장', false, () => storeLink(deleted)], + ['노출 중인 카테고리', true, () => categoryLink()], + ['비활성 카테고리', false, () => categoryLink(inactive)], + ['삭제된 카테고리', false, () => categoryLink(deleted)], + ])( + '%s 링크면 linkTargetAvailable=%s이고 구매자 조회의 노출 여부와 같다', + async (_label, expected, makeLink) => { + const banner = await makeBanner(await makeLink()); + + const list = await service.adminBanners(await admin()); + const single = await service.adminBanner(await admin(), banner.id); + const shown = await buyerRepo.findHomeBanner({ now: new Date() }); + + expect(list.items[0].linkTargetAvailable).toBe(expected); + expect(single.linkTargetAvailable).toBe(expected); + expect(shown !== null).toBe(expected); + }, + ); + + it('대상이 숨겨진 상위 배너는 구매자 조회가 건너뛰고 관리자 목록에는 false로 드러난다', async () => { + const hidden = await makeBanner({ + ...(await productLink(inactive)), + sort_order: 0, + }); + const next = await makeBanner({ sort_order: 1 }); + + const shown = await buyerRepo.findHomeBanner({ now: new Date() }); + const { items } = await service.adminBanners(await admin()); + + expect(shown?.id).toBe(next.id); + expect( + Object.fromEntries(items.map((b) => [b.id, b.linkTargetAvailable])), + ).toEqual({ + [hidden.id.toString()]: false, + [next.id.toString()]: true, + }); + }); + + it('생성·수정 응답도 그 시점의 대상 상태로 판정한다', async () => { + const target = await createStore(prisma); + const created = await service.adminCreateBanner(await admin(), { + placement: 'HOME_MAIN', + imageUrl: ownedUploadUrl('BANNER_IMAGE', await admin(), 'x.png'), + linkType: 'STORE', + linkStoreId: target.id.toString(), + }); + expect(created.linkTargetAvailable).toBe(true); + + await prisma.store.update({ + where: { id: target.id }, + data: { is_active: false }, + }); + const stale = await service.adminBanner( + await admin(), + BigInt(created.id), + ); + expect(stale.linkTargetAvailable).toBe(false); + + // 대상이 내려간 배너는 링크를 바꿔야 저장된다 — 바뀐 링크로 다시 판정한다 + const updated = await service.adminUpdateBanner(await admin(), { + bannerId: created.id, + linkType: 'NONE', + }); + expect(updated.linkTargetAvailable).toBe(true); + }); + }); + describe('이미지 URL 소유권', () => { const rejected = [ [ diff --git a/src/features/product/services/product-admin-banner.service.ts b/src/features/product/services/product-admin-banner.service.ts index c3f74869..afbc1389 100644 --- a/src/features/product/services/product-admin-banner.service.ts +++ b/src/features/product/services/product-admin-banner.service.ts @@ -88,7 +88,7 @@ export class AdminBannerService extends AdminBaseService { const paged = sliceIdCursorPage(rows, normalized.limit); return { - items: paged.items.map(toAdminBannerOutput), + items: await this.toOutputs(paged.items), totalCount, hasMore: paged.hasMore, nextCursor: paged.nextCursor, @@ -100,7 +100,7 @@ export class AdminBannerService extends AdminBaseService { bannerId: bigint, ): Promise { await this.requireAdminContext(accountId); - return toAdminBannerOutput(await this.requireBanner(bannerId)); + return this.toOutput(await this.requireBanner(bannerId)); } async adminCreateBanner( @@ -158,7 +158,7 @@ export class AdminBannerService extends AdminBaseService { }), ); - return toAdminBannerOutput(row); + return this.toOutput(row); } async adminUpdateBanner( @@ -217,7 +217,7 @@ export class AdminBannerService extends AdminBaseService { ); if (!row) throw new DomainException('BANNER_NOT_FOUND'); - return toAdminBannerOutput(row); + return this.toOutput(row); } async adminDeleteBanner( @@ -239,6 +239,19 @@ export class AdminBannerService extends AdminBaseService { return true; } + /** 링크 대상 노출 가능 여부는 저장 후 대상이 내려가며 바뀌므로 응답마다 다시 판정한다. */ + private async toOutputs(rows: Banner[]): Promise { + const visible = await this.repo.findLinkTargetVisibleBannerIds( + rows.map((row) => row.id), + ); + return rows.map((row) => toAdminBannerOutput(row, visible.has(row.id))); + } + + private async toOutput(row: Banner): Promise { + const [output] = await this.toOutputs([row]); + return output; + } + private async requireBanner(bannerId: bigint): Promise { const row = await this.repo.findBannerById(bannerId); if (!row) throw new DomainException('BANNER_NOT_FOUND'); diff --git a/src/features/product/types/product-admin-output.type.ts b/src/features/product/types/product-admin-output.type.ts index f7aa671e..1c2106cb 100644 --- a/src/features/product/types/product-admin-output.type.ts +++ b/src/features/product/types/product-admin-output.type.ts @@ -18,6 +18,7 @@ export interface AdminBannerOutput { linkProductId: string | null; linkStoreId: string | null; linkCategoryId: string | null; + linkTargetAvailable: boolean; startsAt: Date | null; endsAt: Date | null; sortOrder: number;