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 { 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;
}
+2
View File
@@ -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 () => {
@@ -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)
@@ -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);
},
};
}