feat: persist pricing refresh jobs
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-09 11:53:14 -04:00
parent 39b4aab058
commit 409d2cd0ca
9 changed files with 136 additions and 5 deletions
@@ -299,6 +299,20 @@ export const marketValueOverrides = pgTable('market_value_overrides', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', {
id: uuid('id').defaultRandom().primaryKey(),
sellerId: uuid('seller_id').notNull().references(() => sellers.id),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
productId: uuid('product_id').notNull().references(() => products.id),
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
providerId: text('provider_id').notNull(),
status: text('status').default('PENDING').notNull(),
observations: jsonb('observations').default([]).notNull(),
valuation: jsonb('valuation'),
error: text('error'),
...timestamps,
}, (table) => [index('pricing_refresh_jobs_status_created_at_index').on(table.status, table.createdAt)]);
export const inventoryImages = pgTable(
'inventory_images',
{
@@ -0,0 +1,44 @@
import { and, asc, eq } from 'drizzle-orm';
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
export function createPricingRefreshJobRepository(db) {
return {
async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
return created;
},
async claimNext() {
return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.status, 'PENDING'))
.orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1)
.for('update', { skipLocked: true });
if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs)
.set({ status: 'PROCESSING', updatedAt: new Date(), error: null })
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
.returning();
return claimed ?? null;
});
},
async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs)
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.returning();
return completed ?? null;
},
async fail(id, message) {
const [failed] = await db.update(pricingRefreshJobs)
.set({ status: 'FAILED', error: message, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.returning();
return failed ?? null;
},
};
}
@@ -16,6 +16,7 @@ import {
} from '../../src/infrastructure/database/schema.js';
import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js';
import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js';
import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js';
import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js';
const connectionString = process.env.TEST_DATABASE_URL;
@@ -31,6 +32,7 @@ if (!new URL(connectionString).pathname.endsWith('_test')) {
const database = createDatabase(connectionString);
const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
const saleRepository = createSaleRepository(database.db);
async function createFixture() {
@@ -71,7 +73,7 @@ async function createFixture() {
}
beforeEach(async () => {
await database.db.execute(sql`TRUNCATE market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`);
await database.db.execute(sql`TRUNCATE pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`);
});
after(async () => {
@@ -155,3 +157,22 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`);
assert.equal(auditEvent.userId, user.id);
});
test('claims and completes a durable pricing refresh job', async () => {
const { user, seller, product, condition, inventory } = await createFixture();
const [job] = await pricingRefreshJobRepository.create({
sellerId: seller.id,
inventoryItemId: inventory[0].id,
productId: product.id,
conditionId: condition.id,
providerId: 'integration',
}).then((created) => [created]);
const claimed = await pricingRefreshJobRepository.claimNext();
assert.equal(claimed.id, job.id);
assert.equal(claimed.status, 'PROCESSING');
const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 });
assert.equal(completed.status, 'COMPLETED');
assert.deepEqual(completed.valuation, { valueMinor: 2500 });
assert.equal(user.email, 'transactions@example.test');
});