From 31f11f6d565361bdb46106db37fe74063a0d0730 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 17:34:59 -0400 Subject: [PATCH] refactor: make inventory images the publication source Remove the temporary listing-level image count from the schema, validation, DTO, and draft contract. Publication now requires the persisted inventory image repository count. Add migration 0009 and update listing tests and implementation status. Verification: npm test passed 62 tests and integration passed 14 tests. No build script is configured in this repository. --- apps/api/src/infrastructure/database/schema.js | 1 - apps/api/src/modules/listings/domain/listing.js | 9 --------- .../listings/services/listing-service.js | 5 +---- .../tests/application/listing-service.test.js | 17 +++++++++++------ apps/api/tests/domain/listing.test.js | 2 -- apps/api/tests/http/listing-routes.test.js | 10 +++++----- .../integration/listing-repository.test.js | 3 --- .../0009_remove_listing_image_count.sql | 1 + database/migrations/meta/_journal.json | 7 +++++++ docs/IMPLEMENTATION-STATUS.md | 6 +++--- packages/contracts/listing.js | 1 - packages/validation/listing.js | 1 - 12 files changed, 28 insertions(+), 35 deletions(-) create mode 100644 database/migrations/0009_remove_listing_image_count.sql 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();