diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index c3b12b3..885aaaa 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, ilike, or, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm'; import { inventoryItems, @@ -56,12 +56,12 @@ export function createListingRepository(db) { } = filters; const conditions = [ - eq(listings.status, includeSold ? 'AVAILABLE' : 'AVAILABLE'), - or( - eq(inventoryItems.status, 'ACTIVE'), - includeSold ? eq(inventoryItems.status, 'SOLD') : undefined, + inArray( + listings.status, + includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'], ), - ].filter(Boolean); + inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']), + ]; if (typeof category === 'string' && category.trim().length > 0) { conditions.push(eq(products.categoryId, category)); @@ -97,7 +97,14 @@ export function createListingRepository(db) { const offset = (page - 1) * limit; - const results = await db + const [countResult, results] = await Promise.all([ + db + .select({ total: count() }) + .from(listings) + .innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(...conditions)), + db .select({ listing: listings }) .from(listings) .innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) @@ -105,9 +112,13 @@ export function createListingRepository(db) { .where(and(...conditions)) .orderBy(desc(listings.createdAt)) .limit(limit) - .offset(offset); + .offset(offset), + ]); - return results.map(({ listing }) => listing); + return { + items: results.map(({ listing }) => listing), + total: Number(countResult[0]?.total ?? 0), + }; }, async updateStatus(listingId, status) { diff --git a/apps/api/src/modules/search/services/search-service.js b/apps/api/src/modules/search/services/search-service.js index 0a7e85a..7e5700e 100644 --- a/apps/api/src/modules/search/services/search-service.js +++ b/apps/api/src/modules/search/services/search-service.js @@ -8,7 +8,19 @@ export function createSearchService({ listingRepository }) { return { async searchPublic(input = {}) { const normalized = createSearchQuery(input); - return listingRepository.searchPublic(normalized); + const result = await listingRepository.searchPublic(normalized); + const items = Array.isArray(result) ? result : result.items; + const total = Array.isArray(result) ? items.length : result.total; + + return { + items, + pagination: { + page: normalized.page, + limit: normalized.limit, + total, + totalPages: Math.ceil(total / normalized.limit), + }, + }; }, }; } \ 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 index ea2f8e7..f9493f5 100644 --- a/apps/api/tests/application/search-service.test.js +++ b/apps/api/tests/application/search-service.test.js @@ -9,7 +9,10 @@ test('normalizes public search input and delegates to the repository with public listingRepository: { async searchPublic(filters) { calls.push(filters); - return [{ id: 'listing-1', status: 'AVAILABLE' }]; + return { + items: [{ id: 'listing-1', status: 'AVAILABLE' }], + total: 21, + }; }, }, }); @@ -23,7 +26,15 @@ test('normalizes public search input and delegates to the repository with public limit: 10, }); - assert.deepEqual(result, [{ id: 'listing-1', status: 'AVAILABLE' }]); + assert.deepEqual(result, { + items: [{ id: 'listing-1', status: 'AVAILABLE' }], + pagination: { + page: 2, + limit: 10, + total: 21, + totalPages: 3, + }, + }); assert.deepEqual(calls, [{ text: 'pikachu 199 holo', category: 'Cards', @@ -39,7 +50,7 @@ test('allows explicit public sold results and keeps the public filter defaults', const service = createSearchService({ listingRepository: { async searchPublic(filters) { - return filters; + return { items: [filters], total: 1 }; }, }, }); @@ -52,9 +63,17 @@ test('allows explicit public sold results and keeps the public filter defaults', }); assert.deepEqual(filters, { - text: 'charizard', - page: 1, - limit: 25, - includeSold: true, + items: [{ + text: 'charizard', + page: 1, + limit: 25, + includeSold: true, + }], + pagination: { + page: 1, + limit: 25, + total: 1, + totalPages: 1, + }, }); }); diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js new file mode 100644 index 0000000..bc2bd8a --- /dev/null +++ b/apps/api/tests/http/public-search-routes.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +test('returns public search results with pagination metadata', async (testContext) => { + const app = buildApp({ + searchService: { + async searchPublic(filters) { + assert.equal(filters.page, 2); + assert.equal(filters.limit, 5); + assert.equal(filters.includeSold, true); + return { + items: [{ id: 'listing-1', status: 'CLOSED' }], + pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, + }; + }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data.pagination, { + page: 2, + limit: 5, + total: 6, + totalPages: 2, + }); + assert.equal(response.json().data.items[0].id, 'listing-1'); +}); \ No newline at end of file diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index f9a639a..aa91920 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -125,6 +125,44 @@ test('lists the most recent draft entries for an inventory item', async () => { assert.equal(listingsForItem[0].inventoryItemId, inventoryItem.id); }); +test('searches public listings with pagination and hides sold items by default', async () => { + const { category, condition, inventoryItem, product } = await createInventoryFixture(); + + await listingRepository.create({ + inventoryItemId: inventoryItem.id, + productName: product.name, + categoryId: category.id, + conditionId: condition.id, + askingPriceMinor: 2499, + status: 'PENDING', + }); + + const visible = await listingRepository.searchPublic({ page: 1, limit: 10 }); + assert.equal(visible.total, 1); + assert.equal(visible.items.length, 1); + assert.equal(visible.items[0].status, 'PENDING'); + + await database.db + .update(listings) + .set({ status: 'CLOSED' }) + .where(sql`inventory_item_id = ${inventoryItem.id}`); + await database.db + .update(inventoryItems) + .set({ status: 'SOLD' }) + .where(sql`id = ${inventoryItem.id}`); + + const hidden = await listingRepository.searchPublic({ page: 1, limit: 10 }); + assert.equal(hidden.total, 0); + + const included = await listingRepository.searchPublic({ + page: 1, + limit: 10, + includeSold: true, + }); + assert.equal(included.total, 1); + assert.equal(included.items[0].status, 'CLOSED'); +}); + test('persists image metadata only for the owning seller and counts item images', async () => { const { seller, inventoryItem } = await createInventoryFixture(); const image = await inventoryImageRepository.createForSeller({ diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 99c48b7..7fce3bb 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -91,6 +91,7 @@ docker context use orbstack - Listing reads are seller-scoped and return only admin listing DTO fields. - Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. - Public search is available at `GET /api/v1/public/search` through a normalized SearchService boundary with pagination, price filters, text matching, and sold-item visibility controls. +- Public search responses include `items` plus `pagination` metadata (`page`, `limit`, `total`, and `totalPages`); pending listings are visible by default and sold listings require an explicit filter. - Public product pages are available at `GET /api/v1/public/products/:slug`; responses use a public DTO and require an active physical item with an available or pending listing. - `ImageStorage`, traversal-safe local storage, multipart upload transport, and binary signature validation are implemented; derivative processing remains. - Derivative orchestration now defines thumbnail, medium, and large WebP storage outputs behind an injected renderer; the concrete worker-backed renderer remains. @@ -106,7 +107,7 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: `npm test` passed 86 tests; worker tests passed 2 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 87 tests; worker tests passed 2 tests; `npm run test:integration` passed 16 tests against OrbStack PostgreSQL and Redis. ## Next Milestone