feat: add public search service and routes

This commit is contained in:
2026-09-08 18:15:24 -04:00
parent 0350d88d7c
commit f29629c712
6 changed files with 184 additions and 1 deletions
+5
View File
@@ -7,6 +7,7 @@ import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-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) { export function buildApp(dependencies) {
const app = Fastify(); const app = Fastify();
@@ -50,6 +51,10 @@ export function buildApp(dependencies) {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: '/api/v1/admin',
}); });
app.register(registerPublicSearchRoutes, {
...dependencies,
prefix: '/api/v1',
});
return app; return app;
} }
+2
View File
@@ -24,6 +24,7 @@ import { createListingRepository } from '../modules/listings/repositories/listin
import { createListingService } from '../modules/listings/services/listing-service.js'; import { createListingService } from '../modules/listings/services/listing-service.js';
import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductRepository } from '../modules/products/repositories/product-repository.js';
import { createProductService } from '../modules/products/services/product-service.js'; import { createProductService } from '../modules/products/services/product-service.js';
import { createSearchService } from '../modules/search/services/search-service.js';
function parsePort(value) { function parsePort(value) {
const port = Number.parseInt(value ?? '3000', 10); const port = Number.parseInt(value ?? '3000', 10);
@@ -85,6 +86,7 @@ async function startServer() {
}), }),
listingService: createListingService({ listingRepository, inventoryImageRepository }), listingService: createListingService({ listingRepository, inventoryImageRepository }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
searchService: createSearchService({ listingRepository }),
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
@@ -1,4 +1,4 @@
import { and, asc, desc, eq } from 'drizzle-orm'; import { and, asc, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { import {
inventoryItems, inventoryItems,
@@ -44,6 +44,72 @@ export function createListingRepository(db) {
return results.map(({ listing }) => listing); 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) { async updateStatus(listingId, status) {
const [updatedListing] = await db const [updatedListing] = await db
.update(listings) .update(listings)
@@ -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) };
});
}
@@ -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);
},
};
}
@@ -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,
});
});