diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 4b582f1..b2941ee 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -17,6 +17,7 @@ import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-sel import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js'; import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; +import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -76,6 +77,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerAdminPricingRoutes, { + ...dependencies, + prefix: '/api/v1/admin', + }); app.register(registerPublicSearchRoutes, { ...dependencies, prefix: '/api/v1', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index ddcc067..de07999 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -42,6 +42,8 @@ import { createSaleService } from '../modules/sales/services/sale-service.js'; import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js'; import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; +import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js'; +import { createPricingService } from '../modules/pricing/services/pricing-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -67,6 +69,7 @@ async function startServer() { const conditionRepository = createConditionRepository(database.db); const saleRepository = createSaleRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db); + const pricingRepository = createPricingRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -131,6 +134,7 @@ async function startServer() { saleService: createSaleService({ saleRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }), }), + pricingService: createPricingService({ pricingRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index 8dfa5b1..a66fed4 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -261,6 +261,34 @@ export const acquisitions = pgTable('acquisitions', { ...timestamps, }); +export const pricingObservations = pgTable('pricing_observations', { + id: uuid('id').defaultRandom().primaryKey(), + productId: uuid('product_id').notNull().references(() => products.id), + inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id), + providerId: text('provider_id').notNull(), + priceMinor: integer('price_minor').notNull(), + currency: text('currency').notNull(), + sourceIdentifier: text('source_identifier').notNull(), + conditionId: uuid('condition_id').notNull().references(() => conditions.id), + sourceUrl: text('source_url'), + qualityScore: integer('quality_score'), + isOutlier: boolean('is_outlier').default(false).notNull(), + outlierReason: text('outlier_reason'), + observedAt: timestamp('observed_at', { withTimezone: true }).defaultNow().notNull(), + ...timestamps, +}, (table) => [index('pricing_observations_inventory_item_id_index').on(table.inventoryItemId)]); + +export const marketValuations = pgTable('market_valuations', { + id: uuid('id').defaultRandom().primaryKey(), + inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id), + valueMinor: integer('value_minor').notNull(), + currency: text('currency').notNull(), + strategy: text('strategy').notNull(), + confidence: text('confidence').notNull(), + sourceCount: integer('source_count').notNull(), + calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(), +}); + export const inventoryImages = pgTable( 'inventory_images', { diff --git a/apps/api/src/modules/pricing/repositories/pricing-repository.js b/apps/api/src/modules/pricing/repositories/pricing-repository.js new file mode 100644 index 0000000..cbd1634 --- /dev/null +++ b/apps/api/src/modules/pricing/repositories/pricing-repository.js @@ -0,0 +1,43 @@ +import { and, desc, eq } from 'drizzle-orm'; + +import { inventoryItems, marketValuations, pricingObservations, products } from '../../../infrastructure/database/schema.js'; + +export function createPricingRepository(db) { + return { + async addObservationForSeller({ observation, sellerId }) { + const [ownedItem] = await db + .select({ id: inventoryItems.id, productId: inventoryItems.productId }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId))); + if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' }; + const [created] = await db.insert(pricingObservations).values(observation).returning(); + return { status: 'CREATED', observation: created }; + }, + + async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) { + const [ownedItem] = await db + .select({ id: inventoryItems.id }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); + if (!ownedItem) return { status: 'NOT_FOUND' }; + const observations = await db.select().from(pricingObservations) + .where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false))) + .orderBy(desc(pricingObservations.observedAt)); + if (!observations.length) return { status: 'NO_DATA' }; + const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right); + const middle = Math.floor(prices.length / 2); + const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2); + const [valuation] = await db.insert(marketValuations).values({ + inventoryItemId, + valueMinor, + currency: 'USD', + strategy, + confidence: observations.length === 1 ? 'LOW' : 'MEDIUM', + sourceCount: observations.length, + }).returning(); + return { status: 'CALCULATED', valuation, observations }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js new file mode 100644 index 0000000..fb29e76 --- /dev/null +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -0,0 +1,42 @@ +import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing'; +import { createPricingObservationSchema } from '@pokemon-inventory/validation/pricing'; + +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +function validationError(reply, result) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed.', + details: result.error.issues.map(({ message, path }) => ({ message, path })), + }, + }); +} + +export async function registerAdminPricingRoutes(app, options) { + const { + authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, + authorizeUnsafeRequest, + sessionCookieName = 'pokemon_inventory_session', + pricingService, + } = options; + + app.addHook('preHandler', async (request) => { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + request.admin = admin; + }); + + app.post('/pricing/observations', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); + const result = createPricingObservationSchema.safeParse(request.body); + if (!result.success) return validationError(reply, result); + const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId }); + return reply.code(201).send({ data: toAdminPricingObservationDto(observation) }); + }); + + app.post('/inventory/:id/valuation', async (request, reply) => { + const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id }); + return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/pricing/services/pricing-service.js b/apps/api/src/modules/pricing/services/pricing-service.js new file mode 100644 index 0000000..c6d4f44 --- /dev/null +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -0,0 +1,20 @@ +import { createPricingObservation } from '../domain/pricing-observation.js'; +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export function createPricingService({ pricingRepository }) { + return { + async addObservation({ sellerId, ...input }) { + const observation = createPricingObservation(input); + const result = await pricingRepository.addObservationForSeller({ observation, sellerId }); + if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); + return result.observation; + }, + + async calculate({ sellerId, inventoryItemId }) { + const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId }); + if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); + if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404); + return result; + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js new file mode 100644 index 0000000..ab9bb01 --- /dev/null +++ b/apps/api/tests/application/pricing-service.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js'; + +test('records an owned pricing observation and calculates a valuation', async () => { + const service = createPricingService({ + pricingRepository: { + async addObservationForSeller({ observation, sellerId }) { + assert.equal(sellerId, 'seller-1'); + return { status: 'CREATED', observation }; + }, + async calculateForSeller() { + return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] }; + }, + }, + }); + + const observation = await service.addObservation({ + sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1', + providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1', + }); + assert.equal(observation.priceMinor, 2500); + const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' }); + assert.equal(valuation.valuation.valueMinor, 2500); +}); \ No newline at end of file diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js new file mode 100644 index 0000000..4ff6712 --- /dev/null +++ b/apps/api/tests/http/pricing-routes.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +test('accepts a protected pricing observation and valuation request', async (testContext) => { + let csrfChecks = 0; + const app = buildApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authorizeUnsafeRequest: async () => { csrfChecks += 1; }, + pricingService: { + async addObservation(input) { return { id: 'observation-1', ...input }; }, + async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; }, + }, + }); + testContext.after(() => app.close()); + + const observationResponse = await app.inject({ + method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' }, + payload: { productId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958' }, + }); + const valuationResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation' }); + + assert.equal(observationResponse.statusCode, 201); + assert.equal(valuationResponse.statusCode, 200); + assert.equal(valuationResponse.json().data.valueMinor, 2500); + assert.equal(csrfChecks, 1); +}); \ No newline at end of file diff --git a/database/migrations/0015_pricing.sql b/database/migrations/0015_pricing.sql new file mode 100644 index 0000000..9349946 --- /dev/null +++ b/database/migrations/0015_pricing.sql @@ -0,0 +1,30 @@ +CREATE TABLE "pricing_observations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL REFERENCES "products"("id"), + "inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"), + "provider_id" text NOT NULL, + "price_minor" integer NOT NULL, + "currency" text NOT NULL, + "source_identifier" text NOT NULL, + "condition_id" uuid NOT NULL REFERENCES "conditions"("id"), + "source_url" text, + "quality_score" integer, + "is_outlier" boolean DEFAULT false NOT NULL, + "outlier_reason" text, + "observed_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "pricing_observations_inventory_item_id_index" ON "pricing_observations" USING btree ("inventory_item_id"); +--> statement-breakpoint +CREATE TABLE "market_valuations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"), + "value_minor" integer NOT NULL, + "currency" text NOT NULL, + "strategy" text NOT NULL, + "confidence" text NOT NULL, + "source_count" integer NOT NULL, + "calculated_at" timestamp with time zone DEFAULT now() NOT NULL +); \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 9898ee4..81d004f 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1789804800000, "tag": "0014_acquisitions", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1789891200000, + "tag": "0015_pricing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2a19f82..81759d2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,5 +15,6 @@ "./public-inventory": "./public-inventory.js", "./public-product": "./public-product.js", "./public-seller": "./public-seller.js" + ,"./pricing": "./pricing.js" } } \ No newline at end of file diff --git a/packages/contracts/pricing.js b/packages/contracts/pricing.js new file mode 100644 index 0000000..c774f16 --- /dev/null +++ b/packages/contracts/pricing.js @@ -0,0 +1,31 @@ +function toIsoString(value) { return value instanceof Date ? value.toISOString() : value; } + +export function toAdminPricingObservationDto(observation) { + return { + id: observation.id, + productId: observation.productId, + inventoryItemId: observation.inventoryItemId, + providerId: observation.providerId, + priceMinor: observation.priceMinor, + currency: observation.currency, + sourceIdentifier: observation.sourceIdentifier, + conditionId: observation.conditionId, + sourceUrl: observation.sourceUrl ?? null, + qualityScore: observation.qualityScore ?? null, + observedAt: toIsoString(observation.observedAt), + }; +} + +export function toAdminMarketValuationDto({ valuation, observations }) { + return { + id: valuation.id, + inventoryItemId: valuation.inventoryItemId, + valueMinor: valuation.valueMinor, + currency: valuation.currency, + strategy: valuation.strategy, + confidence: valuation.confidence, + sourceCount: valuation.sourceCount, + calculatedAt: toIsoString(valuation.calculatedAt), + observations: observations.map(toAdminPricingObservationDto), + }; +} \ No newline at end of file diff --git a/packages/validation/package.json b/packages/validation/package.json index 8d218a6..0e30a2e 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -10,6 +10,7 @@ "./listing": "./listing.js", "./sale": "./sale.js", "./product": "./product.js" + ,"./pricing": "./pricing.js" }, "dependencies": { "zod": "^4.1.8" diff --git a/packages/validation/pricing.js b/packages/validation/pricing.js new file mode 100644 index 0000000..22f722a --- /dev/null +++ b/packages/validation/pricing.js @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const createPricingObservationSchema = z.object({ + productId: z.uuid(), + inventoryItemId: z.uuid(), + providerId: z.string().trim().min(1).max(100), + priceMinor: z.int().positive(), + currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), + sourceIdentifier: z.string().trim().min(1).max(255), + conditionId: z.uuid(), + sourceUrl: z.string().trim().url().optional(), + qualityScore: z.int().min(0).max(100).optional(), +}).strict(); \ No newline at end of file