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.
This commit is contained in:
2026-09-08 17:34:59 -04:00
parent c728016dec
commit 31f11f6d56
12 changed files with 28 additions and 35 deletions
@@ -275,7 +275,6 @@ export const listings = pgTable('listings', {
.notNull() .notNull()
.references(() => conditions.id), .references(() => conditions.id),
askingPriceMinor: integer('asking_price_minor').notNull(), askingPriceMinor: integer('asking_price_minor').notNull(),
imageCount: integer('image_count').notNull(),
status: listingStatus('status').default('DRAFT').notNull(), status: listingStatus('status').default('DRAFT').notNull(),
notes: text('notes'), notes: text('notes'),
...timestamps, ...timestamps,
@@ -41,7 +41,6 @@ export function createListing({
categoryId, categoryId,
conditionId, conditionId,
askingPriceMinor, askingPriceMinor,
imageCount,
notes, notes,
}) { }) {
return { return {
@@ -50,7 +49,6 @@ export function createListing({
categoryId: requiredText(categoryId, 'Category ID'), categoryId: requiredText(categoryId, 'Category ID'),
conditionId: requiredText(conditionId, 'Condition ID'), conditionId: requiredText(conditionId, 'Condition ID'),
askingPriceMinor: requirePositiveMinorUnit(askingPriceMinor, 'Asking price'), askingPriceMinor: requirePositiveMinorUnit(askingPriceMinor, 'Asking price'),
imageCount: requirePositiveInteger(imageCount, 'Image count'),
status: LISTING_STATUS.DRAFT, status: LISTING_STATUS.DRAFT,
...(typeof notes === 'string' && notes.trim().length > 0 ...(typeof notes === 'string' && notes.trim().length > 0
? { notes: notes.trim() } ? { notes: notes.trim() }
@@ -122,10 +120,3 @@ function requirePositiveMinorUnit(value, fieldName) {
return value; return value;
} }
function requirePositiveInteger(value, fieldName) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${fieldName} must be a positive integer.`);
}
return value;
}
@@ -8,7 +8,6 @@ import { createApplicationError } from '../../../shared/errors/application-error
export function createListingService({ listingRepository, inventoryImageRepository }) { export function createListingService({ listingRepository, inventoryImageRepository }) {
return { return {
async create(input) { async create(input) {
canPublishListing(input);
const listing = createListing(input); const listing = createListing(input);
return listingRepository.create(listing); return listingRepository.create(listing);
}, },
@@ -23,9 +22,7 @@ export function createListingService({ listingRepository, inventoryImageReposito
const status = transitionListingStatus(listing.status, nextStatus); const status = transitionListingStatus(listing.status, nextStatus);
if (status === 'AVAILABLE') { if (status === 'AVAILABLE') {
const imageCount = inventoryImageRepository const imageCount = await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId);
? await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId)
: listing.imageCount;
canPublishListing({ ...listing, imageCount }); canPublishListing({ ...listing, imageCount });
} }
@@ -26,7 +26,6 @@ test('creates a draft listing only after the listing publish requirements are me
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
notes: ' Near mint ', notes: ' Near mint ',
}); });
@@ -37,7 +36,6 @@ test('creates a draft listing only after the listing publish requirements are me
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
notes: 'Near mint', notes: 'Near mint',
}); });
@@ -50,9 +48,8 @@ test('creates a draft listing only after the listing publish requirements are me
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 0, 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); 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', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
}; };
}, },
@@ -79,6 +75,11 @@ test('publishes an owned draft only when its persisted requirements are met', as
return { id, status }; return { id, status };
}, },
}, },
inventoryImageRepository: {
async countByInventoryItemId() {
return 2;
},
},
}); });
assert.deepEqual( assert.deepEqual(
@@ -119,7 +120,6 @@ test('does not publish a listing for sold or archived inventory', async () => {
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
inventoryStatus, inventoryStatus,
status: 'DRAFT', 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.'); throw new Error('A blocked listing must not be updated.');
}, },
}, },
inventoryImageRepository: {
async countByInventoryItemId() {
return 2;
},
},
}); });
await assert.rejects( await assert.rejects(
-2
View File
@@ -15,7 +15,6 @@ test('creates a draft listing with the required public offer fields', () => {
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
notes: ' Near mint ', notes: ' Near mint ',
}); });
@@ -25,7 +24,6 @@ test('creates a draft listing with the required public offer fields', () => {
categoryId: 'category-id', categoryId: 'category-id',
conditionId: 'condition-id', conditionId: 'condition-id',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: LISTING_STATUS.DRAFT, status: LISTING_STATUS.DRAFT,
notes: 'Near mint', notes: 'Near mint',
}); });
+5 -5
View File
@@ -26,7 +26,6 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } =
categoryId: 'category-1', categoryId: 'category-1',
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
}; };
}, },
@@ -38,7 +37,6 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } =
categoryId: 'category-1', categoryId: 'category-1',
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
}]; }];
}, },
@@ -51,13 +49,17 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } =
categoryId: 'category-1', categoryId: 'category-1',
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
}), }),
id, id,
status, status,
}; };
}, },
}, },
inventoryImageRepository: {
async countByInventoryItemId() {
return 2;
},
},
}), }),
inventoryImageService: { inventoryImageService: {
async create(input) { async create(input) {
@@ -90,7 +92,6 @@ test('creates a draft listing for the authenticated seller', async (testContext)
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958', categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
notes: ' Near mint ', notes: ' Near mint ',
}, },
}); });
@@ -122,7 +123,6 @@ test('rejects a listing write without CSRF validation', async (testContext) => {
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958', categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
}, },
}); });
@@ -97,7 +97,6 @@ test('persists a draft listing for a physical inventory item', async () => {
categoryId: category.id, categoryId: category.id,
conditionId: condition.id, conditionId: condition.id,
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
notes: 'Near mint', notes: 'Near mint',
}); });
@@ -117,7 +116,6 @@ test('lists the most recent draft entries for an inventory item', async () => {
categoryId: category.id, categoryId: category.id,
conditionId: condition.id, conditionId: condition.id,
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
}); });
@@ -167,7 +165,6 @@ test('reads and updates listings only through the owning seller scope', async ()
categoryId: category.id, categoryId: category.id,
conditionId: condition.id, conditionId: condition.id,
askingPriceMinor: 2499, askingPriceMinor: 2499,
imageCount: 2,
status: 'DRAFT', status: 'DRAFT',
}); });
@@ -0,0 +1 @@
ALTER TABLE "listings" DROP COLUMN "image_count";
+7
View File
@@ -64,6 +64,13 @@
"when": 1789372800000, "when": 1789372800000,
"tag": "0008_inventory_images", "tag": "0008_inventory_images",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1789459200000,
"tag": "0009_remove_listing_image_count",
"breakpoints": true
} }
] ]
} }
+3 -3
View File
@@ -40,6 +40,7 @@ docker context use orbstack
- `0006_listings`: listing records and lifecycle enum. - `0006_listings`: listing records and lifecycle enum.
- `0007_listing_image_count`: persisted image prerequisite for publication. - `0007_listing_image_count`: persisted image prerequisite for publication.
- `0008_inventory_images`: item-owned image metadata and processing state. - `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. - 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. - 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. 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. 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. 2. Begin acquisition and pricing foundations after the listing/image boundary is complete.
3. 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. 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 ## 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. - 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. - 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. - `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.
-1
View File
@@ -10,7 +10,6 @@ export function toAdminListingDto(listing) {
categoryId: listing.categoryId, categoryId: listing.categoryId,
conditionId: listing.conditionId, conditionId: listing.conditionId,
askingPriceMinor: listing.askingPriceMinor, askingPriceMinor: listing.askingPriceMinor,
imageCount: listing.imageCount,
status: listing.status, status: listing.status,
notes: listing.notes ?? null, notes: listing.notes ?? null,
createdAt: toIsoString(listing.createdAt), createdAt: toIsoString(listing.createdAt),
-1
View File
@@ -7,7 +7,6 @@ export const createListingSchema = z
categoryId: z.uuid(), categoryId: z.uuid(),
conditionId: z.uuid(), conditionId: z.uuid(),
askingPriceMinor: z.int().positive(), askingPriceMinor: z.int().positive(),
imageCount: z.int().positive(),
notes: z.string().trim().max(10000).optional(), notes: z.string().trim().max(10000).optional(),
}) })
.strict(); .strict();