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
4 changes: 4 additions & 0 deletions src/common/errors/error-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ export const ERROR_CATALOG = {
status: HttpStatus.BAD_REQUEST,
message: '유효하지 않은 날짜 형식입니다. (YYYY-MM-DD)',
},
PICKUP_DATE_PAST: {
status: HttpStatus.BAD_REQUEST,
message: '지난 날짜는 조회할 수 없습니다.',
},

// ── 리뷰·좋아요·신고
REVIEW_NOT_FOUND: {
Expand Down
46 changes: 46 additions & 0 deletions src/features/store/dto/inputs/pickup-stores.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';

import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants';
import {
STORE_SORTS,
type StoreSort,
} from '@/features/store/constants/store-sort.constants';

export class PickupStoresInput {
@IsString()
date!: string;

@IsOptional()
@IsString()
time?: string | null;

@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
regionIds?: string[];

@IsOptional()
@IsIn(STORE_SORTS)
sort?: StoreSort | null;

@IsOptional()
@IsInt()
@Min(0)
offset?: number;

@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SEARCH_PAGE_LIMIT)
limit?: number;
}
4 changes: 4 additions & 0 deletions src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface StoreCandidateRow {
min_lead_time_minutes: number;
max_days_ahead: number;
created_at: Date;
latitude: Prisma.Decimal | null;
longitude: Prisma.Decimal | null;
}

const STORE_CANDIDATE_SELECT = {
Expand All @@ -28,6 +30,8 @@ const STORE_CANDIDATE_SELECT = {
min_lead_time_minutes: true,
max_days_ahead: true,
created_at: true,
latitude: true,
longitude: true,
} satisfies Prisma.StoreSelect;

export type StoreSearchCandidateRow = StoreCandidateRow;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ClockService } from '@/common/providers/clock.service';
import { ReviewReadRepository } from '@/features/review';
import { StoreWishlistRepository } from '@/features/review/repositories/store-wishlist.repository';
import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository';
import { StoreRepository } from '@/features/store/repositories/store.repository';
import { StorePickupStoresQueryResolver } from '@/features/store/resolvers/store-pickup-stores-query.resolver';
import { StoreCardService } from '@/features/store/services/store-card.service';
import { StoreListingService } from '@/features/store/services/store-listing.service';
import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service';
import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service';
import type { PrismaClient } from '@/generated/prisma/client';
import type { JwtUser } from '@/global/auth';
import { bookedQuantityProviders } from '@/test/booked-quantity';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createAccount,
createStore,
createStoreWishlist,
} from '@/test/factories';
import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder';

// 2026-08-19(수) 16:00 KST 고정, 조회 날짜는 다음 날(목)
const NOW = new Date('2026-08-19T07:00:00.000Z');
const DATE = '2026-08-20';
const DATE_WEEKDAY = new Date(Date.UTC(2026, 7, 20)).getUTCDay();

// 날짜·시간·정렬 분기 세부 검증은 service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로만 본다.
describe('StorePickupStoresQueryResolver (real DB)', () => {
let resolver: StorePickupStoresQueryResolver;
let clock: ClockService;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [
StoreCardService,
ReviewReadRepository,
StoreStatsRepository,
StorePickupStoresQueryResolver,
StorePickupStoresService,
StorePickupAvailabilityService,
StoreListingService,
StoreRepository,
StoreWishlistRepository,
ClockService,
...bookedQuantityProviders(),
],
});
resolver = module.get(StorePickupStoresQueryResolver);
clock = module.get(ClockService);
prisma = p;
});

afterAll(async () => {
await closeTruncateConnection();
await disconnectTestPrismaClient();
});

beforeEach(async () => {
await truncateAll();
jest.spyOn(clock, 'now').mockReturnValue(NOW);
});

afterEach(() => {
jest.restoreAllMocks();
});

async function openStore(name: string) {
const store = await createStore(prisma, { store_name: name });
await prisma.storeBusinessHour.create({
data: {
store_id: store.id,
day_of_week: DATE_WEEKDAY,
is_closed: false,
open_time: new Date(Date.UTC(1970, 0, 1, 10, 0, 0)),
close_time: new Date(Date.UTC(1970, 0, 1, 20, 0, 0)),
},
});
return store;
}

it('pickupStores: 로그인 사용자의 찜 여부를 채운다', async () => {
const store = await openStore('리졸버 매장');
const account = await createAccount(prisma, { account_type: 'USER' });
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: store.id,
});
const user = { accountId: account.id.toString() } as JwtUser;

const result = await resolver.pickupStores(user, {
date: DATE,
time: '16:30',
});

expect(result.totalCount).toBe(1);
expect(result.items[0].store).toMatchObject({
storeName: '리졸버 매장',
isWishlisted: true,
});
});

it('pickupStores: 비로그인은 isWishlisted=false', async () => {
await openStore('리졸버 매장');

const result = await resolver.pickupStores(undefined, { date: DATE });

expect(result.items[0].store.isWishlisted).toBe(false);
});
});
28 changes: 28 additions & 0 deletions src/features/store/resolvers/store-pickup-stores-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';

import { PickupStoresInput } from '@/features/store/dto/inputs/pickup-stores.input';
import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service';
import type { PickupStoreConnection } from '@/features/store/types/store-pickup-stores-output.type';
import {
CurrentUser,
OptionalJwtAuthGuard,
parseAccountId,
type JwtUser,
} from '@/global/auth';

/** 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. */
@Resolver('Query')
export class StorePickupStoresQueryResolver {
constructor(private readonly service: StorePickupStoresService) {}

@Query('pickupStores')
@UseGuards(OptionalJwtAuthGuard)
pickupStores(
@CurrentUser() user: JwtUser | undefined,
@Args('input') input: PickupStoresInput,
): Promise<PickupStoreConnection> {
const accountId = user ? parseAccountId(user) : undefined;
return this.service.pickupStores(input, accountId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StoreRepository } from '@/features/store/repositories/store.repository'
import { StoreTodayPickupQueryResolver } from '@/features/store/resolvers/store-today-pickup-query.resolver';
import { StoreCardService } from '@/features/store/services/store-card.service';
import { StoreListingService } from '@/features/store/services/store-listing.service';
import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service';
import { StoreTodayPickupService } from '@/features/store/services/store-today-pickup.service';
import type { PrismaClient } from '@/generated/prisma/client';
import { bookedQuantityProviders } from '@/test/booked-quantity';
Expand All @@ -32,6 +33,7 @@ describe('StoreTodayPickup Query Resolver (real DB)', () => {
StoreStatsRepository,
StoreTodayPickupQueryResolver,
StoreTodayPickupService,
StorePickupAvailabilityService,
StoreListingService,
StoreRepository,
StoreWishlistRepository,
Expand Down
4 changes: 3 additions & 1 deletion src/features/store/services/store-detail-mappers.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ function toRatingAverage(stat: ReviewStat | undefined): number {
return roundRatingAverage(stat.average);
}

function toCoordinate(value: { toString(): string } | null): number | null {
export function toCoordinate(
value: { toString(): string } | null,
): number | null {
return value !== null ? Number(value) : null;
}

Expand Down
60 changes: 60 additions & 0 deletions src/features/store/services/store-pickup-availability.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';

import { kstDayBoundaries } from '@/common/utils/kst-time';
import { BookedQuantityPort } from '@/features/store/repositories/booked-quantity.port';
import { StoreRepository } from '@/features/store/repositories/store.repository';
import {
evaluatePickupDay,
type PickupPolicyStore,
} from '@/features/store/services/store-pickup-policy.helper';
import type { TodayPickupSlot } from '@/features/store/types/store-today-pickup-output.type';

@Injectable()
export class StorePickupAvailabilityService {
constructor(
private readonly repo: StoreRepository,
private readonly booked: BookedQuantityPort,
) {}

/**
* 매장별 정책(요일 영업시간·특별휴무·슬롯 간격·리드타임·일일 capacity)으로 그날 예약 가능한 매장만 슬롯과 함께 돌려준다.
* 판정은 달력·주문 재검증과 같은 evaluatePickupDay라 사유는 제외 여부로만 쓴다.
*/
async openSlotsOn(
stores: (PickupPolicyStore & { id: bigint })[],
day: Date,
now: Date,
): Promise<Map<bigint, TodayPickupSlot[]>> {
const open = new Map<bigint, TodayPickupSlot[]>();
if (stores.length === 0) return open;

const storeIds = stores.map((s) => s.id);
// seller가 저장한 closure/capacity 날짜는 UTC date 부분으로 기록되는 전제(dateOnlyUtc 비교)
const { weekday, dateOnlyUtc, dayStartUtc, dayEndUtc } =
kstDayBoundaries(day);
const [businessHours, closedStoreIds, capacities, bookedCounts] =
await Promise.all([
this.repo.findBusinessHoursByWeekday(storeIds, weekday),
this.repo.findSpecialClosureStoreIds(storeIds, dateOnlyUtc),
this.repo.findDailyCapacities(storeIds, dateOnlyUtc),
this.booked.sumByStore(storeIds, dayStartUtc, dayEndUtc),
]);
const hourByStore = new Map(
businessHours.map((h) => [h.store_id.toString(), h]),
);

for (const store of stores) {
const { reason, slots } = evaluatePickupDay({
store,
hour: hourByStore.get(store.id.toString()),
isSpecialClosure: closedStoreIds.has(store.id.toString()),
capacity: capacities.get(store.id),
booked: bookedCounts.get(store.id) ?? 0,
now,
dayStartUtc,
});
if (reason === null) open.set(store.id, slots);
}
return open;
}
}
Loading
Loading