Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `store` ADD COLUMN `profile_image_url` VARCHAR(2048) NULL;
4 changes: 3 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ model Store {
max_days_ahead Int @default(30) @db.UnsignedSmallInt

website_url String? @db.VarChar(2048)
is_active Boolean @default(true) @db.TinyInt
// 매장 프로필(로고) 이미지. 찜/매장별 보기 카드의 원형 프로필 표기용 (figma liked 02·04)
profile_image_url String? @db.VarChar(2048)
is_active Boolean @default(true) @db.TinyInt

created_at DateTime @default(now()) @db.DateTime(3)
updated_at DateTime @updatedAt @db.DateTime(3)
Expand Down
2 changes: 2 additions & 0 deletions prisma/seed/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export async function seedStores(
access_guide_text:
'강남역 3번 출구에서 도보 5분, 1층 케이크 거리 안쪽입니다.',
regular_closure_text: '매주 화요일 정기 휴무',
profile_image_url: 'https://placehold.co/200x200/png?text=Store+A+Logo',
is_active: true,
store_images: {
create: [
Expand Down Expand Up @@ -108,6 +109,7 @@ export async function seedStores(
address_neighborhood: '서교동',
region_id: mapo.id, // 서울 마포구
business_hours_text: '평일 11:00 ~ 21:00',
// B는 프로필 미등록 상태(placeholder UI 확인용) — profile_image_url 미설정
is_active: true,
},
});
Expand Down
2 changes: 2 additions & 0 deletions src/features/product/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export {
// 주문 생성(order feature)의 옵션 검증·가격 스냅샷 입력 타입
type ProductDetailRow,
} from '@/features/product/repositories/product.repository';
// 할인율 산식(0~100). 상품 카드 표기 규칙 — 찜 목록(user feature)이 동일 정책을 공유한다.
export { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper';
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ describe('SellerUpdateStoreBasicInfoInput', () => {
const errors = await validate(dto);
expect(errors[0].property).toBe('storeName');
});

it('profileImageUrl 문자열 통과', async () => {
const dto = build({ profileImageUrl: 'https://cdn.example.com/logo.png' });
expect(await validate(dto)).toHaveLength(0);
});

it('profileImageUrl 이 문자열이 아니면 거절', async () => {
const dto = build({ profileImageUrl: 123 });
const errors = await validate(dto);
expect(errors[0].property).toBe('profileImageUrl');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@ export class SellerUpdateStoreBasicInfoInput {
@IsOptional()
@IsString()
businessHoursText?: string;

@IsOptional()
@IsString()
profileImageUrl?: string;
}
4 changes: 4 additions & 0 deletions src/features/seller/seller-store.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ type SellerStore {
mapProvider: SellerStoreMapProvider!
websiteUrl: String
businessHoursText: String
"""매장 프로필(로고) 이미지 URL. 미등록 시 null."""
profileImageUrl: String
pickupSlotIntervalMinutes: Int!
minLeadTimeMinutes: Int!
maxDaysAhead: Int!
Expand Down Expand Up @@ -118,6 +120,8 @@ input SellerUpdateStoreBasicInfoInput {
mapProvider: SellerStoreMapProvider
websiteUrl: String
businessHoursText: String
"""매장 프로필(로고) 이미지 URL. null 전달 시 제거."""
profileImageUrl: String
}

"""SellerUpsertStoreBusinessHourInput 입력 타입"""
Expand Down
2 changes: 2 additions & 0 deletions src/features/seller/services/seller-store-mappers.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface StoreRow {
map_provider: 'NAVER' | 'KAKAO' | 'NONE';
website_url: string | null;
business_hours_text: string | null;
profile_image_url: string | null;
pickup_slot_interval_minutes: number;
min_lead_time_minutes: number;
max_days_ahead: number;
Expand Down Expand Up @@ -76,6 +77,7 @@ export function toStoreOutput(row: StoreRow): SellerStoreOutput {
mapProvider: row.map_provider,
websiteUrl: row.website_url,
businessHoursText: row.business_hours_text,
profileImageUrl: row.profile_image_url,
pickupSlotIntervalMinutes: row.pickup_slot_interval_minutes,
minLeadTimeMinutes: row.min_lead_time_minutes,
maxDaysAhead: row.max_days_ahead,
Expand Down
37 changes: 37 additions & 0 deletions src/features/seller/services/seller-store-profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client';

import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log';
import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository';
import type { SellerUpdateStoreBasicInfoInput } from '@/features/seller/dto/inputs/seller-update-store-basic-info.input';
import { SellerRepository } from '@/features/seller/repositories/seller.repository';
import { SellerStoreProfileService } from '@/features/seller/services/seller-store-profile.service';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
Expand Down Expand Up @@ -151,5 +152,41 @@ describe('SellerStoreProfileService (real DB)', () => {
expect(result.websiteUrl).toBe('https://example.com');
expect(result.businessHoursText).toBe('월~금 09-18');
});

it('profileImageUrl을 등록·수정한다', async () => {
const { account, store } = await setupSellerWithStore(prisma);

const result = await service.sellerUpdateStoreBasicInfo(account.id, {
profileImageUrl: 'https://cdn.example.com/logo.png',
});

expect(result.profileImageUrl).toBe('https://cdn.example.com/logo.png');
const dbStore = await prisma.store.findUniqueOrThrow({
where: { id: store.id },
});
expect(dbStore.profile_image_url).toBe(
'https://cdn.example.com/logo.png',
);
});

it('profileImageUrl에 null을 전달하면 제거하고, 미전달 시 유지한다', async () => {
const { account, store } = await setupSellerWithStore(prisma);
await prisma.store.update({
where: { id: store.id },
data: { profile_image_url: 'https://cdn.example.com/old-logo.png' },
});

// 미전달(undefined) → 유지
const kept = await service.sellerUpdateStoreBasicInfo(account.id, {
storeName: '이름만 수정',
});
expect(kept.profileImageUrl).toBe('https://cdn.example.com/old-logo.png');

// null 전달 → 제거 (GraphQL 런타임은 명시적 null을 전달할 수 있다)
const removed = await service.sellerUpdateStoreBasicInfo(account.id, {
profileImageUrl: null,
} as unknown as SellerUpdateStoreBasicInfoInput);
expect(removed.profileImageUrl).toBeNull();
});
});
});
9 changes: 9 additions & 0 deletions src/features/seller/services/seller-store-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ export class SellerStoreProfileService
),
}
: {}),
// 프로필(로고) 이미지. null/빈 문자열 전달 시 제거, 미전달(undefined) 시 유지.
...(input.profileImageUrl !== undefined
? {
profile_image_url: cleanNullableText(
input.profileImageUrl,
MAX_URL_LENGTH,
),
}
: {}),
};
}
}
1 change: 1 addition & 0 deletions src/features/seller/types/seller-output.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface SellerStoreOutput {
mapProvider: 'NAVER' | 'KAKAO' | 'NONE';
websiteUrl: string | null;
businessHoursText: string | null;
profileImageUrl: string | null;
pickupSlotIntervalMinutes: number;
minLeadTimeMinutes: number;
maxDaysAhead: number;
Expand Down
5 changes: 5 additions & 0 deletions src/features/store/constants/store-wishlist.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** 찜한 매장 카드의 대표 이미지 수(figma liked 04 시안 기준 3장). */
export const WISHLISTED_STORE_IMAGE_LIMIT = 3;

/** 찜한 매장 목록 기본 페이지 크기(SDL 기본값과 동일). */
export const DEFAULT_WISHLISTED_STORES_LIMIT = 20;
35 changes: 35 additions & 0 deletions src/features/store/dto/inputs/my-wishlisted-stores.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'reflect-metadata';

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

import { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input';

function build(plain: object): MyWishlistedStoresInput {
return plainToInstance(MyWishlistedStoresInput, plain);
}

describe('MyWishlistedStoresInput', () => {
it('빈 입력 통과 (모두 optional)', async () => {
expect(await validate(build({}))).toHaveLength(0);
});

it('offset/limit 통과', async () => {
expect(await validate(build({ offset: 0, limit: 20 }))).toHaveLength(0);
});

it('offset 음수 거절', async () => {
const errors = await validate(build({ offset: -1 }));
expect(errors[0].property).toBe('offset');
});

it('limit 하한(0)·상한(51) 거절', async () => {
expect((await validate(build({ limit: 0 })))[0].property).toBe('limit');
expect((await validate(build({ limit: 51 })))[0].property).toBe('limit');
});

it('정수가 아닌 limit 거절', async () => {
const errors = await validate(build({ limit: 1.5 }));
expect(errors[0].property).toBe('limit');
});
});
14 changes: 14 additions & 0 deletions src/features/store/dto/inputs/my-wishlisted-stores.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IsInt, IsOptional, Max, Min } from 'class-validator';

export class MyWishlistedStoresInput {
@IsOptional()
@IsInt()
@Min(0)
offset?: number;

@IsOptional()
@IsInt()
@Min(1)
@Max(50)
limit?: number;
}
100 changes: 92 additions & 8 deletions src/features/store/repositories/store-wishlist.repository.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';

import { WISHLISTED_STORE_IMAGE_LIMIT } from '@/features/store/constants/store-wishlist.constants';
import { PrismaService } from '@/prisma';

/** 찜한 매장 목록 조회 결과 row. myWishlistedStores 매퍼 입력. */
export interface WishlistedStoreRow {
created_at: Date;
store: {
id: bigint;
store_name: string;
profile_image_url: string | null;
address_city: string | null;
address_neighborhood: string | null;
region: { name: string } | null;
store_images: { image_url: string }[];
};
}

@Injectable()
export class StoreWishlistRepository {
constructor(private readonly prisma: PrismaService) {}

/** 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. */
/**
* 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원.
* 복원(재찜) 시에만 created_at을 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과
* addedAt 표기가 재찜을 반영하되, 이미 active인 찜에 대한 중복 요청(더블 탭·재시도)은
* created_at을 건드리지 않아 멱등 계약을 지킨다.
*/
async upsertStoreWishlist(args: {
accountId: bigint;
storeId: bigint;
now: Date;
}): Promise<void> {
await this.prisma.storeWishlistItem.upsert({
const restored = await this.prisma.storeWishlistItem.updateMany({
where: {
account_id_store_id: {
account_id: args.accountId,
store_id: args.storeId,
},
account_id: args.accountId,
store_id: args.storeId,
deleted_at: { not: null },
},
create: { account_id: args.accountId, store_id: args.storeId },
update: { deleted_at: null, updated_at: args.now },
data: { deleted_at: null, created_at: args.now, updated_at: args.now },
});
if (restored.count > 0) return;

try {
await this.prisma.storeWishlistItem.create({
data: { account_id: args.accountId, store_id: args.storeId },
});
} catch (error) {
// active 찜이 이미 존재(unique 충돌) — 멱등이므로 무시
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return;
}
throw error;
}
}

/** 매장 찜 해제 (멱등). active 항목만 soft-delete. */
Expand Down Expand Up @@ -61,6 +96,55 @@ export class StoreWishlistRepository {
return new Set(rows.map((r) => r.store_id.toString()));
}

/**
* 내가 찜한 매장 목록 (찜 최신순). 비활성/soft-delete 매장은 목록·카운트 모두 제외해
* findWishlistedStoreIds의 가시성 조건과 일관되게 한다.
* soft-delete extension은 nested select에 deleted_at을 주입하지 않으므로 직접 명시한다.
*/
async findWishlistedStores(args: {
accountId: bigint;
offset: number;
limit: number;
}): Promise<{ items: WishlistedStoreRow[]; totalCount: number }> {
const where = {
account_id: args.accountId,
deleted_at: null,
store: { is_active: true, deleted_at: null },
};

const [items, totalCount] = await this.prisma.$transaction([
this.prisma.storeWishlistItem.findMany({
where,
// 같은 밀리초 생성 시 페이지 경계 흔들림 방지를 위해 id를 보조 정렬키로 둔다.
orderBy: [{ created_at: 'desc' }, { id: 'desc' }],
skip: args.offset,
take: args.limit,
select: {
created_at: true,
store: {
select: {
id: true,
store_name: true,
profile_image_url: true,
address_city: true,
address_neighborhood: true,
region: { select: { name: true } },
store_images: {
where: { deleted_at: null },
orderBy: { sort_order: 'asc' },
take: WISHLISTED_STORE_IMAGE_LIMIT,
select: { image_url: true },
},
},
},
},
}),
this.prisma.storeWishlistItem.count({ where }),
]);

return { items, totalCount };
}

/** 활성 USER 계정 여부. 매장 찜은 구매자(USER)만 가능 → 인기 랭킹 무결성 보호. */
async isActiveUserAccount(accountId: bigint): Promise<boolean> {
const account = await this.prisma.account.findFirst({
Expand Down
Loading
Loading