Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions src/features/product/product-admin-banner.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ type AdminBanner {
"""
linkCategoryId: ID
"""
링크 대상이 지금 노출 가능한지. 구매자 앱과 같은 기준(상품·매장·카테고리가 활성·미삭제, 상품은 소속 매장까지)이며,
false면 구매자 앱이 이 배너를 건너뛰고 다음 배너를 노출한다. linkType이 NONE·URL이면 항상 true.
"""
linkTargetAvailable: Boolean!
"""
노출 시작 일시. null이면 시작 제한 없음.
"""
startsAt: DateTime
Expand Down
18 changes: 18 additions & 0 deletions src/features/product/repositories/banner-link-target.helper.ts
Original file line number Diff line number Diff line change
@@ -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 },
],
};
13 changes: 13 additions & 0 deletions src/features/product/repositories/product-admin.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,6 +186,18 @@ export class ProductAdminRepository {
return this.prisma.banner.findFirst({ where: { id: bannerId } });
}

/** 구매자 배너 선택과 같은 조각으로 거른다 — 규칙을 TS로 다시 쓰면 둘이 어긋난다. */
async findLinkTargetVisibleBannerIds(
bannerIds: bigint[],
): Promise<Set<bigint>> {
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(
Expand Down
24 changes: 2 additions & 22 deletions src/features/product/repositories/product.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions src/features/product/services/product-admin-banner.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ import {

describe('AdminBannerService (real DB)', () => {
let service: AdminBannerService;
let buyerRepo: ProductRepository;
let prisma: PrismaClient;

beforeAll(async () => {
Expand All @@ -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;
});

Expand All @@ -60,17 +64,23 @@ 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({
data: {
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,
},
});
Expand Down Expand Up @@ -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 = [
[
Expand Down
21 changes: 17 additions & 4 deletions src/features/product/services/product-admin-banner.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -100,7 +100,7 @@ export class AdminBannerService extends AdminBaseService {
bannerId: bigint,
): Promise<AdminBannerOutput> {
await this.requireAdminContext(accountId);
return toAdminBannerOutput(await this.requireBanner(bannerId));
return this.toOutput(await this.requireBanner(bannerId));
}

async adminCreateBanner(
Expand Down Expand Up @@ -158,7 +158,7 @@ export class AdminBannerService extends AdminBaseService {
}),
);

return toAdminBannerOutput(row);
return this.toOutput(row);
}

async adminUpdateBanner(
Expand Down Expand Up @@ -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(
Expand All @@ -239,6 +239,19 @@ export class AdminBannerService extends AdminBaseService {
return true;
}

/** 링크 대상 노출 가능 여부는 저장 후 대상이 내려가며 바뀌므로 응답마다 다시 판정한다. */
private async toOutputs(rows: Banner[]): Promise<AdminBannerOutput[]> {
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<AdminBannerOutput> {
const [output] = await this.toOutputs([row]);
return output;
}

private async requireBanner(bannerId: bigint): Promise<Banner> {
const row = await this.repo.findBannerById(bannerId);
if (!row) throw new DomainException('BANNER_NOT_FOUND');
Expand Down
1 change: 1 addition & 0 deletions src/features/product/types/product-admin-output.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading