diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 54154bd..a20f3dc 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -7,6 +7,7 @@ import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin- import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; +import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -50,6 +51,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerPublicSearchRoutes, { + ...dependencies, + prefix: '/api/v1', + }); return app; } \ No newline at end of file diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 0e11324..56dab36 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -24,6 +24,7 @@ import { createListingRepository } from '../modules/listings/repositories/listin import { createListingService } from '../modules/listings/services/listing-service.js'; import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductService } from '../modules/products/services/product-service.js'; +import { createSearchService } from '../modules/search/services/search-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -85,6 +86,7 @@ async function startServer() { }), listingService: createListingService({ listingRepository, inventoryImageRepository }), productService: createProductService({ productRepository }), + searchService: createSearchService({ listingRepository }), }); app.addHook('onClose', async () => { diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 4695cc4..c3b12b3 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -1,4 +1,4 @@ -import { and, asc, desc, eq } from 'drizzle-orm'; +import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { inventoryItems, @@ -44,6 +44,72 @@ export function createListingRepository(db) { return results.map(({ listing }) => listing); }, + async searchPublic(filters = {}) { + const { + text = '', + category, + minPriceMinor, + maxPriceMinor, + page = 1, + limit = 20, + includeSold = false, + } = filters; + + const conditions = [ + eq(listings.status, includeSold ? 'AVAILABLE' : 'AVAILABLE'), + or( + eq(inventoryItems.status, 'ACTIVE'), + includeSold ? eq(inventoryItems.status, 'SOLD') : undefined, + ), + ].filter(Boolean); + + if (typeof category === 'string' && category.trim().length > 0) { + conditions.push(eq(products.categoryId, category)); + } + + if (typeof minPriceMinor === 'number') { + conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); + } + + if (typeof maxPriceMinor === 'number') { + conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`); + } + + if (typeof text === 'string' && text.trim().length > 0) { + const searchTerms = text.split(' ').filter(Boolean); + + if (searchTerms.length > 0) { + conditions.push( + or( + ...searchTerms.map((term) => + or( + ilike(listings.productName, `%${term}%`), + ilike(products.name, `%${term}%`), + ilike(products.description, `%${term}%`), + ilike(products.setName, `%${term}%`), + ilike(products.brand, `%${term}%`), + ), + ), + ), + ); + } + } + + const offset = (page - 1) * limit; + + const results = await db + .select({ listing: listings }) + .from(listings) + .innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(...conditions)) + .orderBy(desc(listings.createdAt)) + .limit(limit) + .offset(offset); + + return results.map(({ listing }) => listing); + }, + async updateStatus(listingId, status) { const [updatedListing] = await db .update(listings) diff --git a/apps/api/src/modules/search/routes/public-search-routes.js b/apps/api/src/modules/search/routes/public-search-routes.js new file mode 100644 index 0000000..05b2cc4 --- /dev/null +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -0,0 +1,36 @@ +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +function coerceNumber(value, fallback) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +export async function registerPublicSearchRoutes(app, options) { + const { searchService } = options; + + app.get('/public/search', async (request) => { + if (!searchService || typeof searchService.searchPublic !== 'function') { + throw createApplicationError( + 'SEARCH_SERVICE_UNAVAILABLE', + 'Public search is currently unavailable.', + 503, + ); + } + + const filters = { + query: request.query.query, + category: request.query.category, + minPriceMinor: coerceNumber(request.query.minPriceMinor, undefined), + maxPriceMinor: coerceNumber(request.query.maxPriceMinor, undefined), + page: coerceNumber(request.query.page, 1), + limit: coerceNumber(request.query.limit, 20), + includeSold: request.query.includeSold === 'true' || request.query.includeSold === true, + }; + + return { data: await searchService.searchPublic(filters) }; + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/search/services/search-service.js b/apps/api/src/modules/search/services/search-service.js new file mode 100644 index 0000000..0a7e85a --- /dev/null +++ b/apps/api/src/modules/search/services/search-service.js @@ -0,0 +1,14 @@ +import { createSearchQuery } from '../domain/search-query.js'; + +export function createSearchService({ listingRepository }) { + if (!listingRepository || typeof listingRepository.searchPublic !== 'function') { + throw new TypeError('Public listing repository is required.'); + } + + return { + async searchPublic(input = {}) { + const normalized = createSearchQuery(input); + return listingRepository.searchPublic(normalized); + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/search-service.test.js b/apps/api/tests/application/search-service.test.js new file mode 100644 index 0000000..ea2f8e7 --- /dev/null +++ b/apps/api/tests/application/search-service.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSearchService } from '../../src/modules/search/services/search-service.js'; + +test('normalizes public search input and delegates to the repository with public visibility defaults', async () => { + const calls = []; + const service = createSearchService({ + listingRepository: { + async searchPublic(filters) { + calls.push(filters); + return [{ id: 'listing-1', status: 'AVAILABLE' }]; + }, + }, + }); + + const result = await service.searchPublic({ + query: ' Pikachu #199 / holo ', + category: ' Cards ', + minPriceMinor: 1000, + maxPriceMinor: 5000, + page: 2, + limit: 10, + }); + + assert.deepEqual(result, [{ id: 'listing-1', status: 'AVAILABLE' }]); + assert.deepEqual(calls, [{ + text: 'pikachu 199 holo', + category: 'Cards', + minPriceMinor: 1000, + maxPriceMinor: 5000, + page: 2, + limit: 10, + includeSold: false, + }]); +}); + +test('allows explicit public sold results and keeps the public filter defaults', async () => { + const service = createSearchService({ + listingRepository: { + async searchPublic(filters) { + return filters; + }, + }, + }); + + const filters = await service.searchPublic({ + query: 'charizard', + includeSold: true, + page: 1, + limit: 25, + }); + + assert.deepEqual(filters, { + text: 'charizard', + page: 1, + limit: 25, + includeSold: true, + }); +});