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
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
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' }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort restored wishlists by their re-add time

When a user removes and later re-adds a store, upsertStoreWishlist restores the existing row by updating updated_at while leaving created_at unchanged. Ordering here by created_at therefore places the restored store at its original, potentially much older position and returns that stale value as addedAt, contradicting the query's newest-first contract. Use the restoration timestamp for ordering/output, or reset created_at when restoring the row.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: 복원(재찜) 시 created_at을 재찜 시점으로 갱신해 최신순 정렬과 addedAt 표기가 재찜을 반영하도록 수정. 회귀 테스트 추가.
상품 찜(upsertWishlistItem)도 동일 이슈 — 후속 PR(찜 목록 카드 확장)에서 같은 방식으로 맞출 예정.

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