feat: add public search service and routes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user