diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index 3a5d6f6..6dd5515 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -275,7 +275,6 @@ export const listings = pgTable('listings', { .notNull() .references(() => conditions.id), askingPriceMinor: integer('asking_price_minor').notNull(), - imageCount: integer('image_count').notNull(), status: listingStatus('status').default('DRAFT').notNull(), notes: text('notes'), ...timestamps, diff --git a/apps/api/src/modules/listings/domain/listing.js b/apps/api/src/modules/listings/domain/listing.js index 286de94..0b79e33 100644 --- a/apps/api/src/modules/listings/domain/listing.js +++ b/apps/api/src/modules/listings/domain/listing.js @@ -41,7 +41,6 @@ export function createListing({ categoryId, conditionId, askingPriceMinor, - imageCount, notes, }) { return { @@ -50,7 +49,6 @@ export function createListing({ categoryId: requiredText(categoryId, 'Category ID'), conditionId: requiredText(conditionId, 'Condition ID'), askingPriceMinor: requirePositiveMinorUnit(askingPriceMinor, 'Asking price'), - imageCount: requirePositiveInteger(imageCount, 'Image count'), status: LISTING_STATUS.DRAFT, ...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } @@ -122,10 +120,3 @@ function requirePositiveMinorUnit(value, fieldName) { return value; } -function requirePositiveInteger(value, fieldName) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${fieldName} must be a positive integer.`); - } - - return value; -} diff --git a/apps/api/src/modules/listings/services/listing-service.js b/apps/api/src/modules/listings/services/listing-service.js index 91cfe2b..4c4e946 100644 --- a/apps/api/src/modules/listings/services/listing-service.js +++ b/apps/api/src/modules/listings/services/listing-service.js @@ -8,7 +8,6 @@ import { createApplicationError } from '../../../shared/errors/application-error export function createListingService({ listingRepository, inventoryImageRepository }) { return { async create(input) { - canPublishListing(input); const listing = createListing(input); return listingRepository.create(listing); }, @@ -23,9 +22,7 @@ export function createListingService({ listingRepository, inventoryImageReposito const status = transitionListingStatus(listing.status, nextStatus); if (status === 'AVAILABLE') { - const imageCount = inventoryImageRepository - ? await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId) - : listing.imageCount; + const imageCount = await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId); canPublishListing({ ...listing, imageCount }); } diff --git a/apps/api/tests/application/listing-service.test.js b/apps/api/tests/application/listing-service.test.js index 08bb73a..5c31f45 100644 --- a/apps/api/tests/application/listing-service.test.js +++ b/apps/api/tests/application/listing-service.test.js @@ -26,7 +26,6 @@ test('creates a draft listing only after the listing publish requirements are me categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, notes: ' Near mint ', }); @@ -37,7 +36,6 @@ test('creates a draft listing only after the listing publish requirements are me categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', notes: 'Near mint', }); @@ -50,9 +48,8 @@ test('creates a draft listing only after the listing publish requirements are me categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 0, - imageCount: 2, }), - (error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET', + /Asking price must be a positive integer minor unit/, ); assert.equal(created.length, 1); @@ -70,7 +67,6 @@ test('publishes an owned draft only when its persisted requirements are met', as categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', }; }, @@ -79,6 +75,11 @@ test('publishes an owned draft only when its persisted requirements are met', as return { id, status }; }, }, + inventoryImageRepository: { + async countByInventoryItemId() { + return 2; + }, + }, }); assert.deepEqual( @@ -119,7 +120,6 @@ test('does not publish a listing for sold or archived inventory', async () => { categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, inventoryStatus, status: 'DRAFT', }; @@ -128,6 +128,11 @@ test('does not publish a listing for sold or archived inventory', async () => { throw new Error('A blocked listing must not be updated.'); }, }, + inventoryImageRepository: { + async countByInventoryItemId() { + return 2; + }, + }, }); await assert.rejects( diff --git a/apps/api/tests/domain/listing.test.js b/apps/api/tests/domain/listing.test.js index 64d333b..38b3da2 100644 --- a/apps/api/tests/domain/listing.test.js +++ b/apps/api/tests/domain/listing.test.js @@ -15,7 +15,6 @@ test('creates a draft listing with the required public offer fields', () => { categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, notes: ' Near mint ', }); @@ -25,7 +24,6 @@ test('creates a draft listing with the required public offer fields', () => { categoryId: 'category-id', conditionId: 'condition-id', askingPriceMinor: 2499, - imageCount: 2, status: LISTING_STATUS.DRAFT, notes: 'Near mint', }); diff --git a/apps/api/tests/http/listing-routes.test.js b/apps/api/tests/http/listing-routes.test.js index 5bd785e..9383b2e 100644 --- a/apps/api/tests/http/listing-routes.test.js +++ b/apps/api/tests/http/listing-routes.test.js @@ -26,7 +26,6 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = categoryId: 'category-1', conditionId: 'condition-1', askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', }; }, @@ -38,7 +37,6 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = categoryId: 'category-1', conditionId: 'condition-1', askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', }]; }, @@ -51,13 +49,17 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = categoryId: 'category-1', conditionId: 'condition-1', askingPriceMinor: 2499, - imageCount: 2, }), id, status, }; }, }, + inventoryImageRepository: { + async countByInventoryItemId() { + return 2; + }, + }, }), inventoryImageService: { async create(input) { @@ -90,7 +92,6 @@ test('creates a draft listing for the authenticated seller', async (testContext) categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958', conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', askingPriceMinor: 2499, - imageCount: 2, notes: ' Near mint ', }, }); @@ -122,7 +123,6 @@ test('rejects a listing write without CSRF validation', async (testContext) => { categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958', conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', askingPriceMinor: 2499, - imageCount: 2, }, }); diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js index 9a3d122..f9a639a 100644 --- a/apps/api/tests/integration/listing-repository.test.js +++ b/apps/api/tests/integration/listing-repository.test.js @@ -97,7 +97,6 @@ test('persists a draft listing for a physical inventory item', async () => { categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', notes: 'Near mint', }); @@ -117,7 +116,6 @@ test('lists the most recent draft entries for an inventory item', async () => { categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', }); @@ -167,7 +165,6 @@ test('reads and updates listings only through the owning seller scope', async () categoryId: category.id, conditionId: condition.id, askingPriceMinor: 2499, - imageCount: 2, status: 'DRAFT', }); diff --git a/database/migrations/0009_remove_listing_image_count.sql b/database/migrations/0009_remove_listing_image_count.sql new file mode 100644 index 0000000..8409343 --- /dev/null +++ b/database/migrations/0009_remove_listing_image_count.sql @@ -0,0 +1 @@ +ALTER TABLE "listings" DROP COLUMN "image_count"; \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 635323b..6fed042 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1789372800000, "tag": "0008_inventory_images", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1789459200000, + "tag": "0009_remove_listing_image_count", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 396a88b..5b28f7b 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -40,6 +40,7 @@ docker context use orbstack - `0006_listings`: listing records and lifecycle enum. - `0007_listing_image_count`: persisted image prerequisite for publication. - `0008_inventory_images`: item-owned image metadata and processing state. + - `0009_remove_listing_image_count`: publication no longer stores a listing-level image snapshot. - Isolated integration tests require a database name ending in `_test` and truncate only that database's tables. - Local-only `npm run provision:admin` creates the first Admin User and linked Seller atomically. It requires `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and `INITIAL_SELLER_NAME`, hashes the password with scrypt, and refuses once any User exists. @@ -107,8 +108,7 @@ Latest result: `npm test` passed 62 tests; `npm run test:integration` passed 14 Complete the inventory/listing integrity boundary before starting pricing and acquisition. 1. Add upload transport, MIME/signature/size validation, and processing/derivative lifecycle for persisted image metadata. -2. Remove the temporary listing image-count snapshot after storage-backed image counts are fully established. -3. Begin acquisition and pricing foundations after the listing/image boundary is complete. +2. Begin acquisition and pricing foundations after the listing/image boundary is complete. Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites. @@ -129,7 +129,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema ## Known Gaps - No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables. -- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing and removal of the legacy listing snapshot remain. +- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains. - Sales, acquisitions, pricing, image processing, search, worker jobs, web UI, CI, and deployment services remain unimplemented. - `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes. diff --git a/packages/contracts/listing.js b/packages/contracts/listing.js index ee63050..9beb8d3 100644 --- a/packages/contracts/listing.js +++ b/packages/contracts/listing.js @@ -10,7 +10,6 @@ export function toAdminListingDto(listing) { categoryId: listing.categoryId, conditionId: listing.conditionId, askingPriceMinor: listing.askingPriceMinor, - imageCount: listing.imageCount, status: listing.status, notes: listing.notes ?? null, createdAt: toIsoString(listing.createdAt), diff --git a/packages/validation/listing.js b/packages/validation/listing.js index 4aab809..689e4a1 100644 --- a/packages/validation/listing.js +++ b/packages/validation/listing.js @@ -7,7 +7,6 @@ export const createListingSchema = z categoryId: z.uuid(), conditionId: z.uuid(), askingPriceMinor: z.int().positive(), - imageCount: z.int().positive(), notes: z.string().trim().max(10000).optional(), }) .strict();