diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index b2941ee..a1d26b1 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -14,6 +14,7 @@ import { registerAdminProductRoutes } from '../modules/products/routes/admin-pro import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js'; import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js'; import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js'; +import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js'; import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js'; import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; @@ -105,6 +106,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1', }); + app.register(registerPublicLookupRoutes, { + ...dependencies, + prefix: '/api/v1', + }); return app; } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/public-lookup-routes.js b/apps/api/src/modules/inventory/routes/public-lookup-routes.js new file mode 100644 index 0000000..d7b798b --- /dev/null +++ b/apps/api/src/modules/inventory/routes/public-lookup-routes.js @@ -0,0 +1,7 @@ +export async function registerPublicLookupRoutes(app, options) { + const { catalogLookupRepository } = options; + + app.get('/public/lookups/categories', async () => ({ + data: await catalogLookupRepository.listCategories(), + })); +} \ No newline at end of file diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 1491e78..4f87fb6 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -53,6 +53,7 @@ export function createListingRepository(db) { page = 1, limit = 20, includeSold = false, + sort = 'recent', } = filters; const conditions = [ @@ -96,6 +97,11 @@ export function createListingRepository(db) { } const offset = (page - 1) * limit; + const sortOrder = sort === 'price_low' + ? listings.askingPriceMinor + : sort === 'price_high' + ? desc(listings.askingPriceMinor) + : desc(listings.createdAt); const [countResult, results] = await Promise.all([ db @@ -110,7 +116,7 @@ export function createListingRepository(db) { .innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(products, eq(products.id, inventoryItems.productId)) .where(and(...conditions)) - .orderBy(desc(listings.createdAt)) + .orderBy(sortOrder, desc(listings.createdAt)) .limit(limit) .offset(offset), ]); diff --git a/apps/api/src/modules/search/domain/search-query.js b/apps/api/src/modules/search/domain/search-query.js index f71cdb0..d6e8a1d 100644 --- a/apps/api/src/modules/search/domain/search-query.js +++ b/apps/api/src/modules/search/domain/search-query.js @@ -23,6 +23,7 @@ export function createSearchQuery({ page = 1, limit = 20, includeSold = false, + sort = 'recent', }) { const text = normalizeSearchQuery(query); @@ -50,6 +51,10 @@ export function createSearchQuery({ throw new Error('minPriceMinor must be <= maxPriceMinor'); } + if (!['recent', 'price_low', 'price_high'].includes(sort)) { + throw new Error('sort must be recent, price_low, or price_high'); + } + return { text, ...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}), @@ -58,6 +63,7 @@ export function createSearchQuery({ page, limit, includeSold, + ...(sort !== 'recent' ? { sort } : {}), }; } diff --git a/apps/api/src/modules/search/routes/public-search-routes.js b/apps/api/src/modules/search/routes/public-search-routes.js index 9e5e2e7..4dca836 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -32,6 +32,7 @@ export async function registerPublicSearchRoutes(app, options) { page: coerceNumber(request.query.page, 'page', 1), limit: coerceNumber(request.query.limit, 'limit', 20), includeSold: request.query.includeSold === 'true' || request.query.includeSold === true, + sort: request.query.sort ?? 'recent', }; return { data: await searchService.searchPublic(filters) }; diff --git a/apps/api/tests/domain/search-query.test.js b/apps/api/tests/domain/search-query.test.js index 7fc06ba..3800567 100644 --- a/apps/api/tests/domain/search-query.test.js +++ b/apps/api/tests/domain/search-query.test.js @@ -41,6 +41,11 @@ test('builds a public query filter set that hides sold items by default', () => assert.deepEqual(filters.sort, ['recently_added']); }); +test('accepts only the supported public search sort values', () => { + assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low'); + assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/); +}); + test('normalizes whitespace and punctuation in free-text search terms', () => { assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo'); }); diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js index 95c1a60..6551d79 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -10,6 +10,7 @@ test('returns public search results with pagination metadata', async (testContex assert.equal(filters.page, 2); assert.equal(filters.limit, 5); assert.equal(filters.includeSold, true); + assert.equal(filters.sort, 'price_low'); return { items: [{ id: 'listing-1', status: 'CLOSED' }], pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, @@ -21,7 +22,7 @@ test('returns public search results with pagination metadata', async (testContex const response = await app.inject({ method: 'GET', - url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true', + url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low', }); assert.equal(response.statusCode, 200); @@ -45,4 +46,17 @@ test('rejects malformed numeric search filters', async (testContext) => { assert.equal(response.statusCode, 400); assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER'); +}); + +test('returns active public category options', async (testContext) => { + const app = buildApp({ + catalogLookupRepository: { + async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' }); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]); }); \ No newline at end of file diff --git a/apps/web/src/composables/useCatalogSearch.js b/apps/web/src/composables/useCatalogSearch.js index e009c2b..23aa999 100644 --- a/apps/web/src/composables/useCatalogSearch.js +++ b/apps/web/src/composables/useCatalogSearch.js @@ -2,11 +2,12 @@ import { ref, watch } from 'vue'; import { searchCatalog } from '@/services/catalog-service.js'; -export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialMinPriceMinor, initialMaxPriceMinor, initialPage = 1 } = {}) { +export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) { const query = ref(initialQuery); const category = ref(initialCategory); const minPriceMinor = ref(initialMinPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor); + const sort = ref(initialSort); const page = ref(initialPage); const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } }); const isLoading = ref(false); @@ -19,7 +20,7 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init error.value = null; try { - const nextResult = await searchCatalog({ query: query.value, category: category.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, page: page.value }); + const nextResult = await searchCatalog({ query: query.value, category: category.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value }); if (currentRequestId === requestId) result.value = nextResult; } catch (requestError) { if (currentRequestId === requestId) error.value = requestError; @@ -28,7 +29,7 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init } } - watch([query, category, minPriceMinor, maxPriceMinor], () => { + watch([query, category, minPriceMinor, maxPriceMinor, sort], () => { if (page.value !== 1) page.value = 1; else load(); }); @@ -40,5 +41,5 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init } } - return { query, category, minPriceMinor, maxPriceMinor, page, result, isLoading, error, load, goToPage }; + return { query, category, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage }; } \ No newline at end of file diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue index ff85e99..a682953 100644 --- a/apps/web/src/pages/CatalogPage.vue +++ b/apps/web/src/pages/CatalogPage.vue @@ -1,29 +1,35 @@