feat: reconcile application vertical slices

Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
This commit is contained in:
2026-09-09 22:12:53 -04:00
parent b9ff7445a6
commit b8f089a5d9
78 changed files with 4561 additions and 2057 deletions
+43 -42
View File
@@ -1,32 +1,33 @@
import Fastify from 'fastify'; import Fastify from "fastify";
import cookie from '@fastify/cookie'; import cookie from "@fastify/cookie";
import multipart from '@fastify/multipart'; import multipart from "@fastify/multipart";
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js'; import { registerAuthRoutes } from "../modules/auth/routes/auth-routes.js";
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js'; import { registerAdminInventoryRoutes } from "../modules/inventory/routes/admin-inventory-routes.js";
import { registerAdminLookupRoutes } from '../modules/inventory/routes/admin-lookup-routes.js'; import { registerAdminLookupRoutes } from "../modules/inventory/routes/admin-lookup-routes.js";
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminImageRoutes } from "../modules/inventory/routes/admin-image-routes.js";
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js'; import { registerPublicInventoryRoutes } from "../modules/inventory/routes/public-inventory-routes.js";
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js'; import { registerPublicImageRoutes } from "../modules/inventory/routes/public-image-routes.js";
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminListingRoutes } from "../modules/listings/routes/admin-listing-routes.js";
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js'; import { registerPublicListingRoutes } from "../modules/listings/routes/public-listing-routes.js";
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; import { registerAdminProductRoutes } from "../modules/products/routes/admin-product-routes.js";
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js'; import { registerPublicProductRoutes } from "../modules/products/routes/public-product-routes.js";
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js'; import { registerPublicSearchRoutes } from "../modules/search/routes/public-search-routes.js";
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js'; import { registerPublicSellerRoutes } from "../modules/sellers/routes/public-seller-routes.js";
import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js'; import { registerPublicLookupRoutes } from "../modules/inventory/routes/public-lookup-routes.js";
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js'; import { registerAdminSaleRoutes } from "../modules/sales/routes/admin-sale-routes.js";
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/admin-acquisition-routes.js";
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js'; import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js'; import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
export function buildApp(dependencies) { export function buildApp(dependencies) {
const app = Fastify(); const app = Fastify();
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {
const statusCode = error.statusCode ?? 500; const statusCode = error.statusCode ?? 500;
const message = statusCode >= 500 ? 'Internal server error.' : error.message; const message =
statusCode >= 500 ? "Internal server error." : error.message;
if (statusCode >= 500) { if (statusCode >= 500) {
request.log.error(error); request.log.error(error);
@@ -34,86 +35,86 @@ export function buildApp(dependencies) {
return reply.code(statusCode).send({ return reply.code(statusCode).send({
error: { error: {
code: error.code ?? 'INTERNAL_SERVER_ERROR', code: error.code ?? "INTERNAL_SERVER_ERROR",
message, message,
}, },
}); });
}); });
app.get('/health', async () => ({ data: { status: 'ok' } })); app.get("/health", async () => ({ data: { status: "ok" } }));
app.register(cookie); app.register(cookie);
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } }); app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
app.register(registerAuthRoutes, { app.register(registerAuthRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/auth', prefix: "/api/v1/auth",
}); });
app.register(registerAdminProductRoutes, { app.register(registerAdminProductRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminInventoryRoutes, { app.register(registerAdminInventoryRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminLookupRoutes, { app.register(registerAdminLookupRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminImageRoutes, { app.register(registerAdminImageRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminListingRoutes, { app.register(registerAdminListingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminSaleRoutes, { app.register(registerAdminSaleRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminAcquisitionRoutes, { app.register(registerAdminAcquisitionRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminDashboardRoutes, { app.register(registerAdminDashboardRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminPricingRoutes, { app.register(registerAdminPricingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminProviderMappingRoutes, { app.register(registerAdminProviderMappingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerPublicSearchRoutes, { app.register(registerPublicSearchRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicProductRoutes, { app.register(registerPublicProductRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicListingRoutes, { app.register(registerPublicListingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicInventoryRoutes, { app.register(registerPublicInventoryRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicImageRoutes, { app.register(registerPublicImageRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicSellerRoutes, { app.register(registerPublicSellerRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicLookupRoutes, { app.register(registerPublicLookupRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
return app; return app;
+119 -71
View File
@@ -1,85 +1,106 @@
import dotenv from 'dotenv'; import dotenv from "dotenv";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url)) }); dotenv.config({
path: fileURLToPath(new URL("../../../../.env", import.meta.url)),
});
import { buildApp } from './build-app.js'; import { buildApp } from "./build-app.js";
import { createDatabase } from '../infrastructure/database/client.js'; import { createDatabase } from "../infrastructure/database/client.js";
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js'; import { resolveProjectImageStorageRoot } from "../infrastructure/images/local-image-storage.js";
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js'; import { createPasswordResetNotifier } from "../infrastructure/email/password-reset-notifier.js";
import { createRedisConnection } from '../infrastructure/redis/client.js'; import { createRedisConnection } from "../infrastructure/redis/client.js";
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js'; import { createAuthenticationService } from "../modules/auth/services/authentication-service.js";
import { createMfaService } from '../modules/auth/services/mfa-service.js'; import { createMfaService } from "../modules/auth/services/mfa-service.js";
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js'; import { createSecretProtector } from "../modules/auth/domain/secret-protection.js";
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js'; import { createMfaMethodRepository } from "../modules/auth/repositories/mfa-method-repository.js";
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js'; import { createSecurityAuditRepository } from "../modules/auth/repositories/security-audit-repository.js";
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js'; import { createSessionRepository } from "../modules/auth/repositories/session-repository.js";
import { createUserRepository } from '../modules/auth/repositories/user-repository.js'; import { createUserRepository } from "../modules/auth/repositories/user-repository.js";
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js'; import { createPasswordResetRepository } from "../modules/auth/repositories/password-reset-repository.js";
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js'; import { createRedisLoginRateLimiter } from "../modules/auth/services/login-rate-limiter.js";
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js'; import { createPasswordRecoveryService } from "../modules/auth/services/password-recovery-service.js";
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js'; import { createInventoryItemRepository } from "../modules/inventory/repositories/inventory-item-repository.js";
import { createCatalogLookupRepository } from '../modules/inventory/repositories/catalog-lookup-repository.js'; import { createCatalogLookupRepository } from "../modules/inventory/repositories/catalog-lookup-repository.js";
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js'; import { createInventoryItemService } from "../modules/inventory/services/inventory-item-service.js";
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js'; import { createPublicInventoryService } from "../modules/inventory/services/public-inventory-service.js";
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js'; import { createPublicImageService } from "../modules/inventory/services/public-image-service.js";
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageRepository } from "../modules/inventory/repositories/inventory-image-repository.js";
import { createImageDerivativeJobRepository } from '../modules/inventory/repositories/image-derivative-job-repository.js'; import { createImageDerivativeJobRepository } from "../modules/inventory/repositories/image-derivative-job-repository.js";
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js'; import { createInventoryImageService } from "../modules/inventory/services/inventory-image-service.js";
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js'; import { createLocalImageStorage } from "../infrastructure/images/local-image-storage.js";
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js'; import { createConditionRepository } from "../modules/inventory/repositories/condition-repository.js";
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js'; import { createListingRepository } from "../modules/listings/repositories/listing-repository.js";
import { createListingService } from '../modules/listings/services/listing-service.js'; import { createListingService } from "../modules/listings/services/listing-service.js";
import { createPublicListingService } from '../modules/listings/services/public-listing-service.js'; import { createPublicListingService } from "../modules/listings/services/public-listing-service.js";
import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductRepository } from "../modules/products/repositories/product-repository.js";
import { createProductService } from '../modules/products/services/product-service.js'; import { createProductService } from "../modules/products/services/product-service.js";
import { createPublicProductService } from '../modules/products/services/public-product-service.js'; import { createPublicProductService } from "../modules/products/services/public-product-service.js";
import { createSearchService } from '../modules/search/services/search-service.js'; import { createSearchService } from "../modules/search/services/search-service.js";
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js'; import { createSellerRepository } from "../modules/sellers/repositories/seller-repository.js";
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js'; import { createPublicSellerService } from "../modules/sellers/services/public-seller-service.js";
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js'; import { createSaleRepository } from "../modules/sales/repositories/sale-repository.js";
import { createSaleService } from '../modules/sales/services/sale-service.js'; import { createSaleService } from "../modules/sales/services/sale-service.js";
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionRepository } from "../modules/acquisitions/repositories/acquisition-repository.js";
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js'; import { createAcquisitionService } from "../modules/acquisitions/services/acquisition-service.js";
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; import { createDashboardService } from "../modules/dashboard/services/dashboard-service.js";
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js'; import { createPricingRepository } from "../modules/pricing/repositories/pricing-repository.js";
import { createPricingService } from '../modules/pricing/services/pricing-service.js'; import { createPricingService } from "../modules/pricing/services/pricing-service.js";
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js'; import { createPricingRefreshJobRepository } from "../modules/pricing/repositories/pricing-refresh-job-repository.js";
import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js'; import { createProviderMappingRepository } from "../modules/pricing/repositories/provider-mapping-repository.js";
import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js'; import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
import { createPricingProviderRegistry } from '../modules/pricing/providers/pricing-provider.js'; import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from '../modules/pricing/providers/fixture-pricing-provider.js'; import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
function parsePort(value) { function parsePort(value) {
const port = Number.parseInt(value ?? '3000', 10); const port = Number.parseInt(value ?? "3000", 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) { if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('API_PORT must be an integer between 1 and 65535.'); throw new Error("API_PORT must be an integer between 1 and 65535.");
} }
return port; return port;
} }
async function startServer() { async function startServer() {
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url); const imageStorageRoot = resolveProjectImageStorageRoot(
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
import.meta.url,
);
const database = createDatabase(); const database = createDatabase();
const redis = await createRedisConnection(); const redis = await createRedisConnection();
const productRepository = createProductRepository(database.db); const productRepository = createProductRepository(database.db);
const inventoryItemRepository = createInventoryItemRepository(database.db); const inventoryItemRepository = createInventoryItemRepository(database.db);
const catalogLookupRepository = createCatalogLookupRepository(database.db); const catalogLookupRepository = createCatalogLookupRepository(database.db);
const inventoryImageRepository = createInventoryImageRepository(database.db); const inventoryImageRepository = createInventoryImageRepository(database.db);
const imageDerivativeJobRepository = createImageDerivativeJobRepository(database.db); const imageDerivativeJobRepository = createImageDerivativeJobRepository(
database.db,
);
const listingRepository = createListingRepository(database.db); const listingRepository = createListingRepository(database.db);
const sellerRepository = createSellerRepository(database.db); const sellerRepository = createSellerRepository(database.db);
const conditionRepository = createConditionRepository(database.db); const conditionRepository = createConditionRepository(database.db);
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
const acquisitionRepository = createAcquisitionRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db); const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(
const providerMappingRepository = createProviderMappingRepository(database.db); database.db,
const pricingProviders = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true' );
? createPricingProviderRegistry([createFixturePricingProvider()]) const providerMappingRepository = createProviderMappingRepository(
: createPricingProviderRegistry(); database.db,
);
const providers = [];
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
providers.push(createFixturePricingProvider());
if (process.env.PRICING_HTTP_PROVIDER_ENDPOINT)
providers.push(
createHttpPricingProvider({
id: process.env.PRICING_HTTP_PROVIDER_ID ?? "http-provider",
endpoint: process.env.PRICING_HTTP_PROVIDER_ENDPOINT,
token: process.env.PRICING_HTTP_PROVIDER_TOKEN,
}),
);
const pricingProviders = createPricingProviderRegistry(providers);
const userRepository = createUserRepository(database.db); const userRepository = createUserRepository(database.db);
const sessionRepository = createSessionRepository(database.db); const sessionRepository = createSessionRepository(database.db);
const passwordResetRepository = createPasswordResetRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db);
@@ -105,11 +126,15 @@ async function startServer() {
const app = buildApp({ const app = buildApp({
authenticationService, authenticationService,
authenticateAdmin: (request) => authenticateAdmin: (request) =>
authenticationService.authenticate(request.cookies.pokemon_inventory_session), authenticationService.authenticate(
request.cookies.pokemon_inventory_session,
),
authorizeUnsafeRequest: (sessionToken, csrfToken) => authorizeUnsafeRequest: (sessionToken, csrfToken) =>
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken), authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === "production",
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }), loginRateLimiter: createRedisLoginRateLimiter({
redisClient: redis.client,
}),
mfaService, mfaService,
passwordRecoveryService: createPasswordRecoveryService({ passwordRecoveryService: createPasswordRecoveryService({
passwordResetNotifier, passwordResetNotifier,
@@ -118,7 +143,7 @@ async function startServer() {
userRepository, userRepository,
}), }),
passwordResetRateLimiter: createRedisLoginRateLimiter({ passwordResetRateLimiter: createRedisLoginRateLimiter({
keyPrefix: 'pokemon-inventory:auth:password-reset:', keyPrefix: "pokemon-inventory:auth:password-reset:",
redisClient: redis.client, redisClient: redis.client,
}), }),
inventoryItemService: createInventoryItemService({ inventoryItemService: createInventoryItemService({
@@ -128,39 +153,62 @@ async function startServer() {
}), }),
catalogLookupRepository, catalogLookupRepository,
publicImageService, publicImageService,
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }), publicInventoryService: createPublicInventoryService({
inventoryItemRepository,
publicImageService,
}),
inventoryImageService: createInventoryImageService({ inventoryImageService: createInventoryImageService({
imageStorage: createLocalImageStorage(imageStorageRoot), imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository, imageDerivativeJobRepository,
inventoryImageRepository, inventoryImageRepository,
}), }),
listingService: createListingService({ listingRepository, inventoryImageRepository }), listingService: createListingService({
listingRepository,
inventoryImageRepository,
}),
saleService: createSaleService({ saleRepository }), saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
dashboardService: createDashboardService({ dashboardService: createDashboardService({
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }), inventoryItemService: createInventoryItemService({
listingService: createListingService({ listingRepository, inventoryImageRepository }), conditionRepository,
inventoryItemRepository,
productRepository,
}),
listingService: createListingService({
listingRepository,
inventoryImageRepository,
}),
saleService: createSaleService({ saleRepository }), saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
pricingRepository, pricingRepository,
pricingRefreshJobRepository, pricingRefreshJobRepository,
}), }),
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }), pricingService: createPricingService({
providerMappingService: createProviderMappingService({ providerMappingRepository }), pricingRepository,
publicListingService: createPublicListingService({ listingRepository, publicImageService }), pricingRefreshJobRepository,
providerMappingRepository,
pricingProviderRegistry: pricingProviders,
}),
providerMappingService: createProviderMappingService({
providerMappingRepository,
}),
publicListingService: createPublicListingService({
listingRepository,
publicImageService,
}),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }), searchService: createSearchService({ listingRepository }),
publicSellerService: createPublicSellerService({ sellerRepository }), publicSellerService: createPublicSellerService({ sellerRepository }),
}); });
app.addHook('onClose', async () => { app.addHook("onClose", async () => {
await database.close(); await database.close();
await redis.close(); await redis.close();
}); });
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) }); await app.listen({ host: "0.0.0.0", port: parsePort(process.env.API_PORT) });
} }
startServer().catch((error) => { startServer().catch((error) => {
+324 -263
View File
@@ -10,100 +10,100 @@ import {
timestamp, timestamp,
uniqueIndex, uniqueIndex,
uuid, uuid,
} from 'drizzle-orm/pg-core'; } from "drizzle-orm/pg-core";
import { sql } from 'drizzle-orm'; import { sql } from "drizzle-orm";
export const userRole = pgEnum('user_role', ['ADMIN']); export const userRole = pgEnum("user_role", ["ADMIN"]);
export const inventoryStatus = pgEnum('inventory_status', [ export const inventoryStatus = pgEnum("inventory_status", [
'ACTIVE', "ACTIVE",
'SOLD', "SOLD",
'ARCHIVED', "ARCHIVED",
]); ]);
export const listingStatus = pgEnum('listing_status', [ export const listingStatus = pgEnum("listing_status", [
'DRAFT', "DRAFT",
'AVAILABLE', "AVAILABLE",
'PENDING', "PENDING",
'CLOSED', "CLOSED",
'ARCHIVED', "ARCHIVED",
]); ]);
const timestamps = { const timestamps = {
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }) updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}; };
export const users = pgTable( export const users = pgTable(
'users', "users",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
email: text('email').notNull(), email: text("email").notNull(),
passwordHash: text('password_hash').notNull(), passwordHash: text("password_hash").notNull(),
role: userRole('role').default('ADMIN').notNull(), role: userRole("role").default("ADMIN").notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex('users_email_unique').on(table.email)], (table) => [uniqueIndex("users_email_unique").on(table.email)],
); );
export const sellers = pgTable( export const sellers = pgTable(
'sellers', "sellers",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
displayName: text('display_name').notNull(), displayName: text("display_name").notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)], (table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
); );
export const sessions = pgTable( export const sessions = pgTable(
'sessions', "sessions",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
sessionTokenHash: text('session_token_hash').notNull(), sessionTokenHash: text("session_token_hash").notNull(),
csrfTokenHash: text('csrf_token_hash').notNull(), csrfTokenHash: text("csrf_token_hash").notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }) lastActivityAt: timestamp("last_activity_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
ipAddress: text('ip_address'), ipAddress: text("ip_address"),
userAgent: text('user_agent'), userAgent: text("user_agent"),
revokedAt: timestamp('revoked_at', { withTimezone: true }), revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash), uniqueIndex("sessions_token_hash_unique").on(table.sessionTokenHash),
index('sessions_user_id_index').on(table.userId), index("sessions_user_id_index").on(table.userId),
index('sessions_expires_at_index').on(table.expiresAt), index("sessions_expires_at_index").on(table.expiresAt),
], ],
); );
export const securityAuditEvents = pgTable( export const securityAuditEvents = pgTable(
'security_audit_events', "security_audit_events",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id), userId: uuid("user_id").references(() => users.id),
event: text('event').notNull(), event: text("event").notNull(),
ipAddress: text('ip_address'), ipAddress: text("ip_address"),
userAgent: text('user_agent'), userAgent: text("user_agent"),
metadata: jsonb('metadata').default({}).notNull(), metadata: jsonb("metadata").default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
index('security_audit_events_user_id_index').on(table.userId), index("security_audit_events_user_id_index").on(table.userId),
index('security_audit_events_event_created_at_index').on( index("security_audit_events_event_created_at_index").on(
table.event, table.event,
table.createdAt, table.createdAt,
), ),
@@ -111,89 +111,89 @@ export const securityAuditEvents = pgTable(
); );
export const passwordResetTokens = pgTable( export const passwordResetTokens = pgTable(
'password_reset_tokens', "password_reset_tokens",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
tokenHash: text('token_hash').notNull(), tokenHash: text("token_hash").notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }), usedAt: timestamp("used_at", { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash), uniqueIndex("password_reset_tokens_token_hash_unique").on(table.tokenHash),
index('password_reset_tokens_user_id_index').on(table.userId), index("password_reset_tokens_user_id_index").on(table.userId),
], ],
); );
export const mfaMethods = pgTable( export const mfaMethods = pgTable(
'mfa_methods', "mfa_methods",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
type: text('type').notNull(), type: text("type").notNull(),
secretCiphertext: text('secret_ciphertext').notNull(), secretCiphertext: text("secret_ciphertext").notNull(),
name: text('name').notNull(), name: text("name").notNull(),
isEnabled: boolean('is_enabled').default(false).notNull(), isEnabled: boolean("is_enabled").default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }), lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
disabledAt: timestamp('disabled_at', { withTimezone: true }), disabledAt: timestamp("disabled_at", { withTimezone: true }),
}, },
(table) => [index('mfa_methods_user_id_index').on(table.userId)], (table) => [index("mfa_methods_user_id_index").on(table.userId)],
); );
export const categories = pgTable( export const categories = pgTable(
'categories', "categories",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
name: text('name').notNull(), name: text("name").notNull(),
slug: text('slug').notNull(), slug: text("slug").notNull(),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('categories_name_unique').on(table.name), uniqueIndex("categories_name_unique").on(table.name),
uniqueIndex('categories_slug_unique').on(table.slug), uniqueIndex("categories_slug_unique").on(table.slug),
], ],
); );
export const conditionProfiles = pgTable('condition_profiles', { export const conditionProfiles = pgTable("condition_profiles", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
categoryId: uuid('category_id') categoryId: uuid("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
name: text('name').notNull(), name: text("name").notNull(),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}); });
export const conditions = pgTable( export const conditions = pgTable(
'conditions', "conditions",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
conditionProfileId: uuid('condition_profile_id') conditionProfileId: uuid("condition_profile_id")
.notNull() .notNull()
.references(() => conditionProfiles.id), .references(() => conditionProfiles.id),
name: text('name').notNull(), name: text("name").notNull(),
code: text('code').notNull(), code: text("code").notNull(),
qualityRank: integer('quality_rank').notNull(), qualityRank: integer("quality_rank").notNull(),
description: text('description'), description: text("description"),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('conditions_profile_code_unique').on( uniqueIndex("conditions_profile_code_unique").on(
table.conditionProfileId, table.conditionProfileId,
table.code, table.code,
), ),
@@ -201,229 +201,290 @@ export const conditions = pgTable(
); );
export const products = pgTable( export const products = pgTable(
'products', "products",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid('seller_id') sellerId: uuid("seller_id")
.notNull() .notNull()
.references(() => sellers.id), .references(() => sellers.id),
categoryId: uuid('category_id') categoryId: uuid("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
name: text('name').notNull(), name: text("name").notNull(),
slug: text('slug').notNull(), slug: text("slug").notNull(),
description: text('description'), description: text("description"),
brand: text('brand'), brand: text("brand"),
setName: text('set_name'), setName: text("set_name"),
cardNumber: text('card_number'), cardNumber: text("card_number"),
eventName: text('event_name'), eventName: text("event_name"),
releaseYear: integer('release_year'), releaseYear: integer("release_year"),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug), uniqueIndex("products_seller_slug_unique").on(table.sellerId, table.slug),
check( check(
'products_release_year_valid', "products_release_year_valid",
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`, sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
), ),
], ],
); );
export const inventoryItems = pgTable('inventory_items', { export const inventoryItems = pgTable("inventory_items", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
productId: uuid('product_id') productId: uuid("product_id")
.notNull() .notNull()
.references(() => products.id), .references(() => products.id),
conditionId: uuid('condition_id') conditionId: uuid("condition_id")
.notNull() .notNull()
.references(() => conditions.id), .references(() => conditions.id),
status: inventoryStatus('status').default('ACTIVE').notNull(), status: inventoryStatus("status").default("ACTIVE").notNull(),
notes: text('notes'), notes: text("notes"),
...timestamps, ...timestamps,
}); });
export const acquisitions = pgTable('acquisitions', { export const acquisitions = pgTable("acquisitions", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id') inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
method: text('method').notNull(), method: text("method").notNull(),
costMinor: integer('cost_minor').notNull(), costMinor: integer("cost_minor").notNull(),
currency: text('currency').notNull(), currency: text("currency").notNull(),
date: text('date'), date: text("date"),
source: text('source'), source: text("source"),
location: text('location'), location: text("location"),
shippingMinor: integer('shipping_minor').default(0).notNull(), shippingMinor: integer("shipping_minor").default(0).notNull(),
taxMinor: integer('tax_minor').default(0).notNull(), taxMinor: integer("tax_minor").default(0).notNull(),
otherFeesMinor: integer('other_fees_minor').default(0).notNull(), otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(), estimatedTradeValueMinor: integer("estimated_trade_value_minor")
notes: text('notes'), .default(0)
.notNull(),
notes: text("notes"),
...timestamps, ...timestamps,
}); });
export const pricingObservations = pgTable('pricing_observations', { export const pricingObservations = pgTable(
id: uuid('id').defaultRandom().primaryKey(), "pricing_observations",
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 marketValueOverrides = pgTable('market_value_overrides', {
id: uuid('id').defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
valueMinor: integer('value_minor').notNull(),
currency: text('currency').notNull(),
reason: text('reason').notNull(),
createdBy: uuid('created_by').notNull().references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const pricingProviderMappings = pgTable('pricing_provider_mappings', {
id: uuid('id').defaultRandom().primaryKey(),
productId: uuid('product_id').notNull().references(() => products.id),
providerId: text('provider_id').notNull(),
mappingType: text('mapping_type').notNull(),
externalIdentifier: text('external_identifier'),
searchQuery: text('search_query'),
sourceUrl: text('source_url'),
status: text('status').default('ACTIVE').notNull(),
metadata: jsonb('metadata').default({}).notNull(),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
...timestamps,
});
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(),
attemptCount: integer('attempt_count').default(0).notNull(),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }),
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',
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id') productId: uuid("product_id")
.notNull()
.references(() => products.id),
inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
storageKey: text('storage_key').notNull(), providerId: text("provider_id").notNull(),
originalFilename: text('original_filename').notNull(), externalIdentifier: text("external_identifier"),
mimeType: text('mime_type').notNull(), searchQuery: text("search_query"),
fileSize: integer('file_size').notNull(), priceMinor: integer("price_minor").notNull(),
width: integer('width').notNull(), currency: text("currency").notNull(),
height: integer('height').notNull(), sourceIdentifier: text("source_identifier").notNull(),
altText: text('alt_text'), conditionId: uuid("condition_id")
sortOrder: integer('sort_order').default(0).notNull(), .notNull()
isPrimary: boolean('is_primary').default(false).notNull(), .references(() => conditions.id),
processingStatus: text('processing_status').default('PENDING').notNull(), 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, ...timestamps,
}, },
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)], (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 marketValueOverrides = pgTable("market_value_overrides", {
id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
valueMinor: integer("value_minor").notNull(),
currency: text("currency").notNull(),
reason: text("reason").notNull(),
createdBy: uuid("created_by")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const pricingProviderMappings = pgTable("pricing_provider_mappings", {
id: uuid("id").defaultRandom().primaryKey(),
productId: uuid("product_id")
.notNull()
.references(() => products.id),
providerId: text("provider_id").notNull(),
mappingType: text("mapping_type").notNull(),
externalIdentifier: text("external_identifier"),
searchQuery: text("search_query"),
sourceUrl: text("source_url"),
status: text("status").default("ACTIVE").notNull(),
metadata: jsonb("metadata").default({}).notNull(),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
...timestamps,
});
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(),
attemptCount: integer("attempt_count").default(0).notNull(),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
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",
{
id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
storageKey: text("storage_key").notNull(),
originalFilename: text("original_filename").notNull(),
mimeType: text("mime_type").notNull(),
fileSize: integer("file_size").notNull(),
width: integer("width").notNull(),
height: integer("height").notNull(),
altText: text("alt_text"),
sortOrder: integer("sort_order").default(0).notNull(),
isPrimary: boolean("is_primary").default(false).notNull(),
processingStatus: text("processing_status").default("PENDING").notNull(),
...timestamps,
},
(table) => [
index("inventory_images_inventory_item_id_index").on(table.inventoryItemId),
],
); );
export const imageDerivativeJobs = pgTable( export const imageDerivativeJobs = pgTable(
'image_derivative_jobs', "image_derivative_jobs",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryImageId: uuid('inventory_image_id') inventoryImageId: uuid("inventory_image_id")
.notNull() .notNull()
.references(() => inventoryImages.id), .references(() => inventoryImages.id),
sourceKey: text('source_key').notNull(), sourceKey: text("source_key").notNull(),
status: text('status').default('PENDING').notNull(), status: text("status").default("PENDING").notNull(),
derivatives: jsonb('derivatives').default([]).notNull(), derivatives: jsonb("derivatives").default([]).notNull(),
error: text('error'), error: text("error"),
...timestamps, ...timestamps,
}, },
(table) => [index('image_derivative_jobs_image_id_index').on(table.inventoryImageId)], (table) => [
index("image_derivative_jobs_image_id_index").on(table.inventoryImageId),
],
); );
export const listings = pgTable('listings', { export const listings = pgTable(
id: uuid('id').defaultRandom().primaryKey(), "listings",
inventoryItemId: uuid('inventory_item_id') {
.notNull() id: uuid("id").defaultRandom().primaryKey(),
.references(() => inventoryItems.id), inventoryItemId: uuid("inventory_item_id")
productName: text('product_name').notNull(), .notNull()
categoryId: uuid('category_id').notNull(), .references(() => inventoryItems.id),
conditionId: uuid('condition_id') productName: text("product_name").notNull(),
.notNull() categoryId: uuid("category_id").notNull(),
.references(() => conditions.id), conditionId: uuid("condition_id")
askingPriceMinor: integer('asking_price_minor').notNull(), .notNull()
status: listingStatus('status').default('DRAFT').notNull(), .references(() => conditions.id),
notes: text('notes'), askingPriceMinor: integer("asking_price_minor").notNull(),
...timestamps, status: listingStatus("status").default("DRAFT").notNull(),
}, (table) => [ notes: text("notes"),
index('listings_inventory_item_id_index').on(table.inventoryItemId), ...timestamps,
index('listings_status_index').on(table.status), },
]); (table) => [
index("listings_inventory_item_id_index").on(table.inventoryItemId),
index("listings_status_index").on(table.status),
],
);
export const sales = pgTable('sales', { export const sales = pgTable("sales", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid('seller_id') sellerId: uuid("seller_id")
.notNull() .notNull()
.references(() => sellers.id), .references(() => sellers.id),
status: text('status').default('COMPLETED').notNull(), status: text("status").default("COMPLETED").notNull(),
saleDate: text('sale_date').notNull(), saleDate: text("sale_date").notNull(),
saleLocation: text('sale_location'), saleLocation: text("sale_location"),
soldPriceMinor: integer('sold_price_minor').notNull(), soldPriceMinor: integer("sold_price_minor").notNull(),
currency: text('currency').notNull(), currency: text("currency").notNull(),
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(), platformFeesMinor: integer("platform_fees_minor").default(0).notNull(),
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(), paymentProcessingFeesMinor: integer("payment_processing_fees_minor")
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(), .default(0)
taxesMinor: integer('taxes_minor').default(0).notNull(), .notNull(),
otherFeesMinor: integer('other_fees_minor').default(0).notNull(), shippingCostMinor: integer("shipping_cost_minor").default(0).notNull(),
buyerName: text('buyer_name'), taxesMinor: integer("taxes_minor").default(0).notNull(),
buyerContact: text('buyer_contact'), otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
notes: text('notes'), buyerName: text("buyer_name"),
voidReason: text('void_reason'), buyerContact: text("buyer_contact"),
voidedAt: timestamp('voided_at', { withTimezone: true }), notes: text("notes"),
voidReason: text("void_reason"),
voidedAt: timestamp("voided_at", { withTimezone: true }),
...timestamps, ...timestamps,
}); });
export const saleItems = pgTable( export const saleItems = pgTable(
'sale_items', "sale_items",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
saleId: uuid('sale_id') saleId: uuid("sale_id")
.notNull() .notNull()
.references(() => sales.id), .references(() => sales.id),
inventoryItemId: uuid('inventory_item_id') inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
soldPriceMinor: integer('sold_price_minor').notNull(), soldPriceMinor: integer("sold_price_minor").notNull(),
costBasisMinor: integer('cost_basis_minor').notNull(), costBasisMinor: integer("cost_basis_minor").notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId), uniqueIndex("sale_items_inventory_item_id_unique").on(
index('sale_items_sale_id_index').on(table.saleId), table.inventoryItemId,
),
index("sale_items_sale_id_index").on(table.saleId),
], ],
); );
@@ -1,6 +1,10 @@
import { and, desc, eq } from 'drizzle-orm'; import { and, desc, eq } from "drizzle-orm";
import { acquisitions, inventoryItems, products } from '../../../infrastructure/database/schema.js'; import {
acquisitions,
inventoryItems,
products,
} from "../../../infrastructure/database/schema.js";
export function createAcquisitionRepository(db) { export function createAcquisitionRepository(db) {
return { return {
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
.select({ id: inventoryItems.id }) .select({ id: inventoryItems.id })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId))); .where(
and(
eq(inventoryItems.id, acquisition.inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: 'NOT_FOUND' }; if (!ownedItem) return { status: "NOT_FOUND" };
const [created] = await transaction.insert(acquisitions).values(acquisition).returning(); const [created] = await transaction
return { status: 'CREATED', acquisition: created }; .insert(acquisitions)
.values(acquisition)
.returning();
return { status: "CREATED", acquisition: created };
}); });
}, },
@@ -22,7 +34,10 @@ export function createAcquisitionRepository(db) {
return db return db
.select({ acquisition: acquisitions }) .select({ acquisition: acquisitions })
.from(acquisitions) .from(acquisitions)
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, acquisitions.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(acquisitions.createdAt)) .orderBy(desc(acquisitions.createdAt))
@@ -1,42 +1,64 @@
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition'; import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition'; import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminAcquisitionRoutes(app, options) { export async function registerAdminAcquisitionRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
acquisitionService, acquisitionService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/acquisitions', async (request) => ({ app.get("/acquisitions", async (request) => ({
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto), data: (
await acquisitionService.list({ sellerId: request.admin.sellerId })
).map(toAdminAcquisitionDto),
})); }));
app.post('/acquisitions', async (request, reply) => { app.post("/acquisitions", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createAcquisitionSchema.safeParse(request.body); const result = createAcquisitionSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId }); const acquisition = await acquisitionService.create({
...result.data,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) }); return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
}); });
} }
@@ -1,13 +1,23 @@
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js'; import {
import { createApplicationError } from '../../../shared/errors/application-error.js'; createAcquisition,
calculateAcquisitionCostBasis,
} from "../domain/acquisition.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createAcquisitionService({ acquisitionRepository }) { export function createAcquisitionService({ acquisitionRepository }) {
return { return {
async create({ sellerId, ...input }) { async create({ sellerId, ...input }) {
const acquisition = createAcquisition(input); const acquisition = createAcquisition(input);
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId }); const result = await acquisitionRepository.createForSeller({
if (result.status === 'NOT_FOUND') { acquisition,
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); sellerId,
});
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
} }
return { return {
@@ -1,20 +1,33 @@
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard'; import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export async function registerAdminDashboardRoutes(app, options) { export async function registerAdminDashboardRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
dashboardService, dashboardService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/dashboard', async (request) => ({ app.get("/dashboard", async (request) => ({
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })), data: toAdminDashboardDto(
await dashboardService.get({ sellerId: request.admin.sellerId }),
),
})); }));
} }
@@ -1,7 +1,23 @@
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) { export function createDashboardService({
productService,
inventoryItemService,
listingService,
saleService,
acquisitionService,
pricingRepository,
pricingRefreshJobRepository,
}) {
return { return {
async get({ sellerId }) { async get({ sellerId }) {
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([ const [
products,
inventory,
listings,
sales,
acquisitions,
valuations,
failedPricingJobs,
] = await Promise.all([
productService.listBySellerId(sellerId), productService.listBySellerId(sellerId),
inventoryItemService.listBySellerId(sellerId), inventoryItemService.listBySellerId(sellerId),
listingService.list({ sellerId }), listingService.list({ sellerId }),
@@ -11,32 +27,47 @@ export function createDashboardService({ productService, inventoryItemService, l
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0, pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
]); ]);
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status)); const activeListings = listings.filter(({ status }) =>
const completedSales = sales.filter(({ status }) => status !== 'VOIDED'); ["AVAILABLE", "PENDING"].includes(status),
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation])); );
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
const valuationByItem = new Map(
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
);
const staleThreshold = 7 * 24 * 60 * 60 * 1000; const staleThreshold = 7 * 24 * 60 * 60 * 1000;
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0); const total = (values, field) =>
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
return { return {
counts: { counts: {
products: products.length, products: products.length,
inventory: inventory.length, inventory: inventory.length,
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length, activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length, .length,
soldInventory: inventory.filter(({ status }) => status === "SOLD")
.length,
availableListings: activeListings.length, availableListings: activeListings.length,
sales: completedSales.length, sales: completedSales.length,
acquisitions: acquisitions.length, acquisitions: acquisitions.length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length, missingImages: listings.filter(({ imageCount }) => imageCount < 1)
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length, .length,
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length, missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
.length,
stalePricing: valuations.filter(
({ calculatedAt }) =>
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
).length,
failedPricingJobs, failedPricingJobs,
}, },
financials: { financials: {
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'), totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'), totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'), totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
totalProfitMinor: total(completedSales, 'profitMinor'), totalProfitMinor: total(completedSales, "profitMinor"),
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'), totalMarketValueMinor: total(
[...valuationByItem.values()],
"valueMinor",
),
}, },
recentSales: completedSales.slice(0, 5), recentSales: completedSales.slice(0, 5),
recentAcquisitions: acquisitions.slice(0, 5), recentAcquisitions: acquisitions.slice(0, 5),
@@ -1,11 +1,13 @@
export async function registerPublicLookupRoutes(app, options) { export async function registerPublicLookupRoutes(app, options) {
const { catalogLookupRepository } = options; const { catalogLookupRepository } = options;
app.get('/public/lookups/categories', async () => ({ app.get("/public/lookups/categories", async () => ({
data: await catalogLookupRepository.listCategories(), data: await catalogLookupRepository.listCategories(),
})); }));
app.get('/public/lookups/conditions', async () => ({ app.get("/public/lookups/conditions", async () => ({
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })), data: (await catalogLookupRepository.listConditions()).map(
({ id, name, code }) => ({ id, name, code }),
),
})); }));
} }
@@ -1,4 +1,14 @@
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm'; import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql,
} from "drizzle-orm";
import { import {
conditions, conditions,
@@ -7,12 +17,15 @@ import {
marketValueOverrides, marketValueOverrides,
marketValuations, marketValuations,
products, products,
} from '../../../infrastructure/database/schema.js'; } from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) { export function createListingRepository(db) {
return { return {
async create(listing) { async create(listing) {
const [createdListing] = await db.insert(listings).values(listing).returning(); const [createdListing] = await db
.insert(listings)
.values(listing)
.returning();
return createdListing; return createdListing;
}, },
@@ -28,18 +41,28 @@ export function createListingRepository(db) {
const [result] = await db const [result] = await db
.select({ listing: listings, inventoryStatus: inventoryItems.status }) .select({ listing: listings, inventoryStatus: inventoryItems.status })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId))); .where(
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
);
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null; return result
? { ...result.listing, inventoryStatus: result.inventoryStatus }
: null;
}, },
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
const results = await db const results = await db
.select({ listing: listings }) .select({ listing: listings })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(listings.createdAt)); .orderBy(desc(listings.createdAt));
@@ -49,7 +72,7 @@ export function createListingRepository(db) {
async searchPublic(filters = {}) { async searchPublic(filters = {}) {
const { const {
text = '', text = "",
category, category,
conditionId, conditionId,
minPriceMinor, minPriceMinor,
@@ -57,35 +80,40 @@ export function createListingRepository(db) {
page = 1, page = 1,
limit = 20, limit = 20,
includeSold = false, includeSold = false,
sort = 'recent', sort = "recent",
} = filters; } = filters;
const conditions = [ const conditions = [
inArray( inArray(
listings.status, listings.status,
includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'], includeSold
? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"],
),
inArray(
inventoryItems.status,
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
), ),
inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']),
]; ];
if (typeof category === 'string' && category.trim().length > 0) { if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category)); conditions.push(eq(products.categoryId, category));
} }
if (typeof conditionId === 'string' && conditionId.trim().length > 0) { if (typeof conditionId === "string" && conditionId.trim().length > 0) {
conditions.push(eq(inventoryItems.conditionId, conditionId)); conditions.push(eq(inventoryItems.conditionId, conditionId));
} }
if (typeof minPriceMinor === 'number') { if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
} }
if (typeof maxPriceMinor === 'number') { if (typeof maxPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`); conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
} }
if (typeof text === 'string' && text.trim().length > 0) { if (typeof text === "string" && text.trim().length > 0) {
const searchTerms = text.split(' ').filter(Boolean); const searchTerms = text.split(" ").filter(Boolean);
if (searchTerms.length > 0) { if (searchTerms.length > 0) {
conditions.push( conditions.push(
@@ -105,28 +133,35 @@ export function createListingRepository(db) {
} }
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const sortOrder = sort === 'price_low' const sortOrder =
? listings.askingPriceMinor sort === "price_low"
: sort === 'price_high' ? listings.askingPriceMinor
? desc(listings.askingPriceMinor) : sort === "price_high"
: desc(listings.createdAt); ? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const [countResult, results] = await Promise.all([ const [countResult, results] = await Promise.all([
db db
.select({ total: count() }) .select({ total: count() })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions)), .where(and(...conditions)),
db db
.select({ listing: listings }) .select({ listing: listings })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
.innerJoin(products, eq(products.id, inventoryItems.productId)) inventoryItems,
.where(and(...conditions)) eq(inventoryItems.id, listings.inventoryItemId),
.orderBy(sortOrder, desc(listings.createdAt)) )
.limit(limit) .innerJoin(products, eq(products.id, inventoryItems.productId))
.offset(offset), .where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
]); ]);
return { return {
@@ -139,22 +174,40 @@ export function createListingRepository(db) {
const [result] = await db const [result] = await db
.select({ listing: listings, condition: conditions }) .select({ listing: listings, condition: conditions })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId)) .innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.where(and( .where(
eq(listings.id, listingId), and(
inArray(listings.status, ['AVAILABLE', 'PENDING']), eq(listings.id, listingId),
eq(inventoryItems.status, 'ACTIVE'), inArray(listings.status, ["AVAILABLE", "PENDING"]),
)); eq(inventoryItems.status, "ACTIVE"),
),
);
if (!result) return null; if (!result) return null;
const [override] = await db.select().from(marketValueOverrides) const [override] = await db
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId)) .select()
.orderBy(desc(marketValueOverrides.createdAt)).limit(1); .from(marketValueOverrides)
const [valuation] = await db.select().from(marketValuations) .where(
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId)) eq(
.orderBy(desc(marketValuations.calculatedAt)).limit(1); marketValueOverrides.inventoryItemId,
result.listing.inventoryItemId,
),
)
.orderBy(desc(marketValueOverrides.createdAt))
.limit(1);
const [valuation] = await db
.select()
.from(marketValuations)
.where(
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
)
.orderBy(desc(marketValuations.calculatedAt))
.limit(1);
return { return {
...result.listing, ...result.listing,
conditionName: result.condition.name, conditionName: result.condition.name,
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) { function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`); throw new Error(`${fieldName} is required.`);
} }
@@ -19,28 +19,30 @@ export function createPricingObservation({
inventoryItemId, inventoryItemId,
providerId, providerId,
priceMinor, priceMinor,
currency = 'USD', currency = "USD",
sourceIdentifier, sourceIdentifier,
conditionId, conditionId,
sourceUrl, sourceUrl,
qualityScore, qualityScore,
}) { }) {
return { return {
productId: requiredText(productId, 'Product ID'), productId: requiredText(productId, "Product ID"),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'), inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
providerId: requiredText(providerId, 'Provider ID'), providerId: requiredText(providerId, "Provider ID"),
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'), priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
currency: requiredText(currency, 'Currency'), currency: requiredText(currency, "Currency"),
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'), sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
conditionId: requiredText(conditionId, 'Condition ID'), conditionId: requiredText(conditionId, "Condition ID"),
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}), ...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
...(typeof qualityScore === 'number' ? { qualityScore } : {}), ? { sourceUrl: sourceUrl.trim() }
: {}),
...(typeof qualityScore === "number" ? { qualityScore } : {}),
}; };
} }
export function calculateMedianPriceMinor(prices) { export function calculateMedianPriceMinor(prices) {
if (!Array.isArray(prices) || prices.length === 0) { if (!Array.isArray(prices) || prices.length === 0) {
throw new Error('At least one price is required.'); throw new Error("At least one price is required.");
} }
const sorted = [...prices].sort((left, right) => left - right); const sorted = [...prices].sort((left, right) => left - right);
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
if (!Array.isArray(prices) || prices.length < 4) return new Set(); if (!Array.isArray(prices) || prices.length < 4) return new Set();
const sorted = [...prices].sort((left, right) => left - right); const sorted = [...prices].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2); const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2 const median =
? sorted[middle] sorted.length % 2
: (sorted[middle - 1] + sorted[middle]) / 2; ? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const lowerFence = median / 3; const lowerFence = median / 3;
const upperFence = median * 3; const upperFence = median * 3;
return new Set(prices.filter((price) => price < lowerFence || price > upperFence)); return new Set(
prices.filter((price) => price < lowerFence || price > upperFence),
);
} }
@@ -1,18 +1,20 @@
import { createPricingProvider } from './pricing-provider.js'; import { createPricingProvider } from "./pricing-provider.js";
export function createFixturePricingProvider() { export function createFixturePricingProvider() {
return createPricingProvider({ return createPricingProvider({
id: 'fixture-provider', id: "fixture-provider",
async observe({ productId, inventoryItemId, conditionId }) { async observe({ productId, inventoryItemId, conditionId }) {
return [{ return [
productId, {
inventoryItemId, productId,
conditionId, inventoryItemId,
priceMinor: 2500, conditionId,
currency: 'USD', priceMinor: 2500,
sourceIdentifier: `fixture-${inventoryItemId}`, currency: "USD",
qualityScore: 50, sourceIdentifier: `fixture-${inventoryItemId}`,
}]; qualityScore: 50,
},
];
}, },
}); });
} }
@@ -1,10 +1,10 @@
export function createPricingProvider({ id, observe }) { export function createPricingProvider({ id, observe }) {
if (typeof id !== 'string' || id.trim().length === 0) { if (typeof id !== "string" || id.trim().length === 0) {
throw new Error('Pricing provider id is required.'); throw new Error("Pricing provider id is required.");
} }
if (typeof observe !== 'function') { if (typeof observe !== "function") {
throw new TypeError('Pricing provider observe function is required.'); throw new TypeError("Pricing provider observe function is required.");
} }
return Object.freeze({ return Object.freeze({
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
async observe(input) { async observe(input) {
const observations = await observe(input); const observations = await observe(input);
if (!Array.isArray(observations)) { if (!Array.isArray(observations)) {
throw new Error(`Pricing provider ${id} must return an observation array.`); throw new Error(
`Pricing provider ${id} must return an observation array.`,
);
} }
return observations; return observations;
}, },
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
} }
export function createPricingProviderRegistry(providers = []) { export function createPricingProviderRegistry(providers = []) {
const providerMap = new Map(providers.map((provider) => [provider.id, provider])); const providerMap = new Map(
providers.map((provider) => [provider.id, provider]),
);
return { return {
get(providerId) { get(providerId) {
@@ -1,93 +1,179 @@
import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm'; import { and, asc, desc, eq, isNull, lte, or } from "drizzle-orm";
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js'; import { pricingRefreshJobs } from "../../../infrastructure/database/schema.js";
export function createPricingRefreshJobRepository(db) { export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3; const maxAttempts = 3;
return { return {
async create(job) { async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning(); const [created] = await db
.insert(pricingRefreshJobs)
.values(job)
.returning();
return created; return created;
}, },
async claimNext() { async claimNext() {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs) const [job] = await transaction
.where(and( .select()
eq(pricingRefreshJobs.status, 'PENDING'), .from(pricingRefreshJobs)
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())), .where(
)) and(
eq(pricingRefreshJobs.status, "PENDING"),
or(
isNull(pricingRefreshJobs.nextAttemptAt),
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
),
),
)
.orderBy(asc(pricingRefreshJobs.createdAt)) .orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1) .limit(1)
.for('update', { skipLocked: true }); .for("update", { skipLocked: true });
if (!job) return null; if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs) const [claimed] = await transaction
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING'))) .set({
status: "PROCESSING",
attemptCount: pricingRefreshJobs.attemptCount,
updatedAt: new Date(),
error: null,
nextAttemptAt: null,
})
.where(
and(
eq(pricingRefreshJobs.id, job.id),
eq(pricingRefreshJobs.status, "PENDING"),
),
)
.returning(); .returning();
return claimed ?? null; return claimed ?? null;
}); });
}, },
async findByIdForSeller(id, sellerId) { async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(pricingRefreshJobs) const [job] = await db
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId))); .select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
),
);
return job ?? null; return job ?? null;
}, },
async countFailedForSeller(sellerId) { async countFailedForSeller(sellerId) {
const rows = await db.select({ id: pricingRefreshJobs.id }) const rows = await db
.select({ id: pricingRefreshJobs.id })
.from(pricingRefreshJobs) .from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'))); .where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
),
);
return rows.length; return rows.length;
}, },
async listForSeller(sellerId) { async listForSeller(sellerId) {
return db.select().from(pricingRefreshJobs) return db
.select()
.from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.sellerId, sellerId)) .where(eq(pricingRefreshJobs.sellerId, sellerId))
.orderBy(desc(pricingRefreshJobs.createdAt)); .orderBy(desc(pricingRefreshJobs.createdAt));
}, },
async retryForSeller(id, sellerId) { async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs) const [job] = await db
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1))) .set({
status: "PENDING",
error: null,
observations: [],
valuation: null,
nextAttemptAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
),
)
.returning(); .returning();
return job ?? null; return job ?? null;
}, },
async complete(id, observations, valuation) { async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs) const [completed] = await db
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))) .set({
status: "COMPLETED",
observations,
valuation,
error: null,
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning(); .returning();
return completed ?? null; return completed ?? null;
}, },
async fail(id, message) { async fail(id, message) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [current] = await transaction.select().from(pricingRefreshJobs) const [current] = await transaction
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))); .select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
);
if (!current) return null; if (!current) return null;
const attemptCount = current.attemptCount + 1; const attemptCount = current.attemptCount + 1;
const retryable = attemptCount < maxAttempts; const retryable = attemptCount < maxAttempts;
const [updated] = await transaction.update(pricingRefreshJobs) const [updated] = await transaction
.update(pricingRefreshJobs)
.set({ .set({
status: retryable ? 'PENDING' : 'FAILED', status: retryable ? "PENDING" : "FAILED",
error: message, error: message,
attemptCount, attemptCount,
updatedAt: new Date(), updatedAt: new Date(),
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null, nextAttemptAt: retryable
? new Date(Date.now() + 2 ** attemptCount * 1000)
: null,
}) })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))) .where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning(); .returning();
return updated ?? null; return updated ?? null;
}); });
}, },
async purgeCompletedBefore(sellerId, cutoff) { async purgeCompletedBefore(sellerId, cutoff) {
const deleted = await db.delete(pricingRefreshJobs) const deleted = await db
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff))) .delete(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "COMPLETED"),
lte(pricingRefreshJobs.updatedAt, cutoff),
),
)
.returning({ id: pricingRefreshJobs.id }); .returning({ id: pricingRefreshJobs.id });
return deleted.length; return deleted.length;
}, },
@@ -1,7 +1,14 @@
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray } from "drizzle-orm";
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js'; import {
import { identifyOutlierPrices } from '../domain/pricing-observation.js'; inventoryItems,
marketValueOverrides,
marketValuations,
pricingObservations,
products,
securityAuditEvents,
} from "../../../infrastructure/database/schema.js";
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
export function createPricingRepository(db) { export function createPricingRepository(db) {
return { return {
@@ -10,76 +17,164 @@ export function createPricingRepository(db) {
.select({ id: inventoryItems.id, productId: inventoryItems.productId }) .select({ id: inventoryItems.id, productId: inventoryItems.productId })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' }; and(
const [created] = await db.insert(pricingObservations).values(observation).returning(); eq(inventoryItems.id, observation.inventoryItemId),
return { status: 'CREATED', observation: created }; 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' }) { async calculateForSeller({
inventoryItemId,
sellerId,
strategy = "MEDIAN",
}) {
const [ownedItem] = await db const [ownedItem] = await db
.select({ id: inventoryItems.id }) .select({ id: inventoryItems.id })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const observations = await db.select().from(pricingObservations) eq(inventoryItems.id, inventoryItemId),
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false))) 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)); .orderBy(desc(pricingObservations.observedAt));
if (!observations.length) return { status: 'NO_DATA' }; if (!observations.length) return { status: "NO_DATA" };
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right); const prices = observations
.map(({ priceMinor }) => priceMinor)
.sort((left, right) => left - right);
const middle = Math.floor(prices.length / 2); const middle = Math.floor(prices.length / 2);
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2); const valueMinor =
const [valuation] = await db.insert(marketValuations).values({ prices.length % 2
inventoryItemId, ? prices[middle]
valueMinor, : Math.round((prices[middle - 1] + prices[middle]) / 2);
currency: 'USD', const [valuation] = await db
strategy, .insert(marketValuations)
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM', .values({
sourceCount: observations.length, inventoryItemId,
}).returning(); valueMinor,
return { status: 'CALCULATED', valuation, observations }; currency: "USD",
strategy,
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
sourceCount: observations.length,
})
.returning();
return { status: "CALCULATED", valuation, observations };
}, },
async listLatestValuationsForSeller(sellerId) { async listLatestValuationsForSeller(sellerId) {
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id }) const rows = await db
.select({
valuation: marketValuations,
inventoryItemId: inventoryItems.id,
})
.from(marketValuations) .from(marketValuations)
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, marketValuations.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(marketValuations.calculatedAt)); .orderBy(desc(marketValuations.calculatedAt));
const latest = new Map(); const latest = new Map();
for (const row of rows) { for (const row of rows) {
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation); if (!latest.has(row.inventoryItemId))
latest.set(row.inventoryItemId, row.valuation);
} }
return [...latest.values()]; return [...latest.values()];
}, },
async markOutliersForSeller({ inventoryItemId, sellerId }) { async markOutliersForSeller({ inventoryItemId, sellerId }) {
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems) const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId)); eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
const prices = observations.map(({ priceMinor }) => priceMinor); const prices = observations.map(({ priceMinor }) => priceMinor);
const outlierPrices = identifyOutlierPrices(prices); const outlierPrices = identifyOutlierPrices(prices);
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId)); await db
.update(pricingObservations)
.set({ isOutlier: false, outlierReason: null })
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
if (outlierPrices.size) { if (outlierPrices.size) {
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' }) await db
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices]))); .update(pricingObservations)
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
inArray(pricingObservations.priceMinor, [...outlierPrices]),
),
);
} }
return { status: 'UPDATED' }; return { status: "UPDATED" };
}, },
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) { async createOverride({
inventoryItemId,
sellerId,
actorUserId,
valueMinor,
currency,
reason,
}) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems) const [ownedItem] = await transaction
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning(); eq(inventoryItems.id, inventoryItemId),
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } }); eq(products.sellerId, sellerId),
return { status: 'CREATED', override }; ),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const [override] = await transaction
.insert(marketValueOverrides)
.values({
inventoryItemId,
valueMinor,
currency,
reason,
createdBy: actorUserId,
})
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: "MARKET_VALUE_OVERRIDDEN",
metadata: { inventoryItemId, overrideId: override.id, reason },
});
return { status: "CREATED", override };
}); });
}, },
}; };
@@ -1,25 +1,42 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from "drizzle-orm";
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js'; import {
pricingProviderMappings,
products,
} from "../../../infrastructure/database/schema.js";
export function createProviderMappingRepository(db) { export function createProviderMappingRepository(db) {
return { return {
async createForSeller(mapping, sellerId) { async createForSeller(mapping, sellerId) {
const [ownedProduct] = await db.select({ id: products.id }).from(products) const [ownedProduct] = await db
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId))); .select({ id: products.id })
if (!ownedProduct) return { status: 'NOT_FOUND' }; .from(products)
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning(); .where(
return { status: 'CREATED', mapping: created }; and(
eq(products.id, mapping.productId),
eq(products.sellerId, sellerId),
),
);
if (!ownedProduct) return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingProviderMappings)
.values(mapping)
.returning();
return { status: "CREATED", mapping: created };
}, },
async listForSeller(productId, sellerId) { async listForSeller(productId, sellerId) {
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) return db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and( .where(
eq(pricingProviderMappings.productId, productId), and(
eq(products.sellerId, sellerId), eq(pricingProviderMappings.productId, productId),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(products.sellerId, sellerId),
)) eq(pricingProviderMappings.status, "ACTIVE"),
),
)
.then((rows) => rows.map(({ mapping }) => mapping)); .then((rows) => rows.map(({ mapping }) => mapping));
}, },
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
const conditions = [ const conditions = [
eq(pricingProviderMappings.productId, productId), eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId), eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(pricingProviderMappings.status, "ACTIVE"),
]; ];
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId)); if (providerId)
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(...conditions)); .where(and(...conditions));
return row?.mapping ?? null; return row?.mapping ?? null;
}, },
async disableForSeller(id, sellerId) { async disableForSeller(id, sellerId) {
const [mapping] = await db.select().from(pricingProviderMappings) const [mapping] = await db
.select()
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and( .where(
eq(pricingProviderMappings.id, id), and(
eq(products.sellerId, sellerId), eq(pricingProviderMappings.id, id),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(products.sellerId, sellerId),
)); eq(pricingProviderMappings.status, "ACTIVE"),
if (!mapping) return { status: 'NOT_FOUND' }; ),
const [updated] = await db.update(pricingProviderMappings) );
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() }) if (!mapping) return { status: "NOT_FOUND" };
const [updated] = await db
.update(pricingProviderMappings)
.set({
status: "DISABLED",
disabledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pricingProviderMappings.id, id)) .where(eq(pricingProviderMappings.id, id))
.returning(); .returning();
return { status: 'DISABLED', mapping: updated }; return { status: "DISABLED", mapping: updated };
}, },
}; };
} }
@@ -1,48 +1,85 @@
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing'; import {
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing'; toAdminMarketValuationDto,
toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing";
import {
createMarketValueOverrideSchema,
createPricingObservationSchema,
} from "@pokemon-inventory/validation/pricing";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminPricingRoutes(app, options) { export async function registerAdminPricingRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
pricingService, pricingService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.post('/pricing/observations', async (request, reply) => { app.post("/pricing/observations", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createPricingObservationSchema.safeParse(request.body); const result = createPricingObservationSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId }); const observation = await pricingService.addObservation({
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) }); ...result.data,
sellerId: request.admin.sellerId,
});
return reply
.code(201)
.send({ data: toAdminPricingObservationDto(observation) });
}); });
app.post('/inventory/:id/valuation', async (request, reply) => { app.post("/inventory/:id/valuation", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id }); request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const valuation = await pricingService.calculate({
sellerId: request.admin.sellerId,
inventoryItemId: request.params.id,
});
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) }); return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
}); });
app.post('/inventory/:id/valuation/override', async (request, reply) => { app.post("/inventory/:id/valuation/override", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createMarketValueOverrideSchema.safeParse(request.body); const result = createMarketValueOverrideSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const override = await pricingService.override({ const override = await pricingService.override({
@@ -54,8 +91,11 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override }); return reply.code(201).send({ data: override });
}); });
app.post('/inventory/:id/pricing/refresh', async (request, reply) => { app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const { providerId, productId, conditionId } = request.body ?? {}; const { providerId, productId, conditionId } = request.body ?? {};
const job = await pricingService.enqueueRefresh({ const job = await pricingService.enqueueRefresh({
sellerId: request.admin.sellerId, sellerId: request.admin.sellerId,
@@ -64,11 +104,16 @@ export async function registerAdminPricingRoutes(app, options) {
productId, productId,
conditionId, conditionId,
}); });
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } }); return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
}); });
app.get('/jobs/:id', async (request) => { app.get("/jobs/:id", async (request) => {
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id }); const job = await pricingService.getJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return { return {
data: { data: {
id: job.id, id: job.id,
@@ -81,16 +126,31 @@ export async function registerAdminPricingRoutes(app, options) {
}; };
}); });
app.get('/jobs', async (request) => ({ app.get("/jobs", async (request) => ({
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({ data: (
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId, await pricingService.listJobs({ sellerId: request.admin.sellerId })
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt, ).map((job) => ({
id: job.id,
status: job.status,
providerId: job.providerId,
inventoryItemId: job.inventoryItemId,
error: job.error ?? null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
})), })),
})); }));
app.post('/jobs/:id/retry', async (request, reply) => { app.post("/jobs/:id/retry", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id }); request.cookies[sessionCookieName],
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } }); request.headers["x-csrf-token"],
);
const job = await pricingService.retryJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
}); });
} }
@@ -1,47 +1,79 @@
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping'; import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping'; import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminProviderMappingRoutes(app, options) { export async function registerAdminProviderMappingRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
providerMappingService, providerMappingService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/products/:id/provider-mappings', async (request) => ({ app.get("/products/:id/provider-mappings", async (request) => ({
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto), data: (
await providerMappingService.list({
productId: request.params.id,
sellerId: request.admin.sellerId,
})
).map(toAdminProviderMappingDto),
})); }));
app.post('/products/:id/provider-mappings', async (request, reply) => { app.post("/products/:id/provider-mappings", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createProviderMappingSchema.safeParse(request.body); const result = createProviderMappingSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId }); const mapping = await providerMappingService.create({
...result.data,
productId: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) }); return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
}); });
app.post('/provider-mappings/:id/disable', async (request, reply) => { app.post("/provider-mappings/:id/disable", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId }); request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const mapping = await providerMappingService.disable({
id: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) }); return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
}); });
} }
@@ -1,66 +1,184 @@
import { createPricingObservation } from '../domain/pricing-observation.js'; import { createPricingObservation } from "../domain/pricing-observation.js";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) { export function createPricingService({
pricingRepository,
pricingProviderRegistry,
pricingRefreshJobRepository,
providerMappingRepository,
}) {
return { return {
async addObservation({ sellerId, ...input }) { async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input); const observation = createPricingObservation(input);
const result = await pricingRepository.addObservationForSeller({ observation, sellerId }); const result = await pricingRepository.addObservationForSeller({
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); observation,
sellerId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.observation; return result.observation;
}, },
async markOutliers({ sellerId, inventoryItemId }) { async markOutliers({ sellerId, inventoryItemId }) {
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId }); const result = await pricingRepository.markOutliersForSeller({
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result; return result;
}, },
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) { async override({
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400); sellerId,
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400); actorUserId,
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() }); inventoryItemId,
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); valueMinor,
currency = "USD",
reason,
}) {
if (!Number.isInteger(valueMinor) || valueMinor < 0)
throw createApplicationError(
"INVALID_OVERRIDE_VALUE",
"Override value must be a non-negative integer minor unit.",
400,
);
if (typeof reason !== "string" || reason.trim().length < 3)
throw createApplicationError(
"OVERRIDE_REASON_REQUIRED",
"An override reason is required.",
400,
);
const result = await pricingRepository.createOverride({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency: currency.toUpperCase(),
reason: reason.trim(),
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.override; return result.override;
}, },
async calculate({ sellerId, inventoryItemId }) { async calculate({ sellerId, inventoryItemId }) {
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId }); await pricingRepository.markOutliersForSeller({
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId }); sellerId,
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); inventoryItemId,
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404); });
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; return result;
}, },
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { async refresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
const provider = pricingProviderRegistry?.get(providerId); const provider = pricingProviderRegistry?.get(providerId);
if (!provider) { if (!provider) {
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503); throw createApplicationError(
"PRICING_PROVIDER_UNAVAILABLE",
"Pricing provider is not configured.",
503,
);
} }
let providerObservations; let providerObservations;
try { try {
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId }); providerObservations = await provider.observe({
inventoryItemId,
productId,
conditionId,
});
} catch (error) { } catch (error) {
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502); throw createApplicationError(
"PRICING_PROVIDER_FAILED",
error.message,
502,
);
} }
const observations = []; const observations = [];
for (const input of providerObservations) { for (const input of providerObservations) {
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId })); observations.push(
await this.addObservation({
sellerId,
...input,
providerId: provider.id,
inventoryItemId,
productId,
conditionId,
}),
);
} }
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) }; return {
observations,
valuation: await this.calculate({ sellerId, inventoryItemId }),
};
}, },
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { async enqueueRefresh({
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503); sellerId,
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId); inventoryItemId,
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409); providerId,
productId,
conditionId,
}) {
if (!pricingRefreshJobRepository)
throw createApplicationError(
"PRICING_QUEUE_UNAVAILABLE",
"Pricing refresh queue is unavailable.",
503,
);
const mapping = await providerMappingRepository?.findActiveForSeller(
productId,
sellerId,
providerId,
);
if (!mapping)
throw createApplicationError(
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
"No active pricing provider mapping exists for this product.",
409,
);
const job = await pricingRefreshJobRepository.create({ const job = await pricingRefreshJobRepository.create({
sellerId, sellerId,
inventoryItemId, inventoryItemId,
providerId: mapping.providerId, providerId: mapping.providerId,
externalIdentifier: mapping.externalIdentifier,
searchQuery: mapping.searchQuery,
productId, productId,
conditionId, conditionId,
}); });
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
}, },
async getJob({ sellerId, jobId }) { async getJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId); const job = await pricingRefreshJobRepository?.findByIdForSeller(
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404); jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_FOUND",
"Pricing job was not found.",
404,
);
return job; return job;
}, },
@@ -78,8 +204,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
}, },
async retryJob({ sellerId, jobId }) { async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId); const job = await pricingRefreshJobRepository?.retryForSeller(
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409); jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_RETRYABLE",
"Pricing job was not found or is not failed.",
409,
);
return job; return job;
}, },
}; };
@@ -1,18 +1,34 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createProviderMappingService({ providerMappingRepository }) { export function createProviderMappingService({ providerMappingRepository }) {
return { return {
async create({ sellerId, ...input }) { async create({ sellerId, ...input }) {
const result = await providerMappingRepository.createForSeller(input, sellerId); const result = await providerMappingRepository.createForSeller(
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404); input,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PRODUCT_NOT_FOUND",
"Product was not found.",
404,
);
return result.mapping; return result.mapping;
}, },
async list({ productId, sellerId }) { async list({ productId, sellerId }) {
return providerMappingRepository.listForSeller(productId, sellerId); return providerMappingRepository.listForSeller(productId, sellerId);
}, },
async disable({ id, sellerId }) { async disable({ id, sellerId }) {
const result = await providerMappingRepository.disableForSeller(id, sellerId); const result = await providerMappingRepository.disableForSeller(
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404); id,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PROVIDER_MAPPING_NOT_FOUND",
"Provider mapping was not found.",
404,
);
return result.mapping; return result.mapping;
}, },
}; };
+53 -32
View File
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) { function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`); throw new Error(`${fieldName} is required.`);
} }
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
const [, year, month, day] = match; const [, year, month, day] = match;
const parsedDate = new Date(`${date}T00:00:00.000Z`); const parsedDate = new Date(`${date}T00:00:00.000Z`);
if ( if (
Number.isNaN(parsedDate.getTime()) Number.isNaN(parsedDate.getTime()) ||
|| parsedDate.getUTCFullYear() !== Number(year) parsedDate.getUTCFullYear() !== Number(year) ||
|| parsedDate.getUTCMonth() + 1 !== Number(month) parsedDate.getUTCMonth() + 1 !== Number(month) ||
|| parsedDate.getUTCDate() !== Number(day) parsedDate.getUTCDate() !== Number(day)
) { ) {
throw new Error(`${fieldName} must be a valid ISO date.`); throw new Error(`${fieldName} must be a valid ISO date.`);
} }
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
} }
function requireCurrency(value) { function requireCurrency(value) {
const currency = requiredText(value, 'Currency').toUpperCase(); const currency = requiredText(value, "Currency").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) { if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error('Currency must be a three-letter ISO currency code.'); throw new Error("Currency must be a three-letter ISO currency code.");
} }
return currency; return currency;
@@ -58,7 +58,7 @@ export function createSale({
saleDate, saleDate,
saleLocation, saleLocation,
soldPriceMinor, soldPriceMinor,
currency = 'USD', currency = "USD",
platformFeesMinor = 0, platformFeesMinor = 0,
paymentProcessingFeesMinor = 0, paymentProcessingFeesMinor = 0,
shippingCostMinor = 0, shippingCostMinor = 0,
@@ -69,42 +69,55 @@ export function createSale({
notes, notes,
}) { }) {
return { return {
sellerId: requiredText(sellerId, 'Seller ID'), sellerId: requiredText(sellerId, "Seller ID"),
saleDate: requireIsoDate(saleDate, 'Sale date'), saleDate: requireIsoDate(saleDate, "Sale date"),
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0 ...(typeof saleLocation === "string" && saleLocation.trim().length > 0
? { saleLocation: saleLocation.trim() } ? { saleLocation: saleLocation.trim() }
: {}), : {}),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
currency: requireCurrency(currency), currency: requireCurrency(currency),
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'), platformFeesMinor: requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
),
paymentProcessingFeesMinor: requireNonNegativeMinorUnit( paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
paymentProcessingFeesMinor, paymentProcessingFeesMinor,
'Payment processing fees', "Payment processing fees",
), ),
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'), shippingCostMinor: requireNonNegativeMinorUnit(
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'), shippingCostMinor,
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'), "Shipping cost",
...(typeof buyerName === 'string' && buyerName.trim().length > 0 ),
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
...(typeof buyerName === "string" && buyerName.trim().length > 0
? { buyerName: buyerName.trim() } ? { buyerName: buyerName.trim() }
: {}), : {}),
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0 ...(typeof buyerContact === "string" && buyerContact.trim().length > 0
? { buyerContact: buyerContact.trim() } ? { buyerContact: buyerContact.trim() }
: {}), : {}),
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}), ...(typeof notes === "string" && notes.trim().length > 0
? { notes: notes.trim() }
: {}),
}; };
} }
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) { export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
return { return {
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'), costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
}; };
} }
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) { export function createSaleItem({
saleId,
inventoryItemId,
soldPriceMinor,
costBasisMinor,
}) {
return { return {
saleId: requiredText(saleId, 'Sale ID'), saleId: requiredText(saleId, "Sale ID"),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'), inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
...validateSaleItem({ soldPriceMinor, costBasisMinor }), ...validateSaleItem({ soldPriceMinor, costBasisMinor }),
}; };
} }
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
taxesMinor = 0, taxesMinor = 0,
otherFeesMinor = 0, otherFeesMinor = 0,
}) { }) {
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price'); const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'); const platformFees = requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
);
const processingFees = requireNonNegativeMinorUnit( const processingFees = requireNonNegativeMinorUnit(
paymentProcessingFeesMinor, paymentProcessingFeesMinor,
'Payment processing fees', "Payment processing fees",
); );
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'); const shipping = requireNonNegativeMinorUnit(
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes'); shippingCostMinor,
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'); "Shipping cost",
);
const taxes = requireNonNegativeMinorUnit(taxesMinor, "Taxes");
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, "Other fees");
return salePrice - platformFees - processingFees - shipping - taxes - otherFees; return (
salePrice - platformFees - processingFees - shipping - taxes - otherFees
);
} }
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray } from "drizzle-orm";
import { import {
inventoryItems, inventoryItems,
@@ -6,14 +6,20 @@ import {
saleItems, saleItems,
sales, sales,
securityAuditEvents, securityAuditEvents,
} from '../../../infrastructure/database/schema.js'; } from "../../../infrastructure/database/schema.js";
export function createSaleRepository(db) { export function createSaleRepository(db) {
return { return {
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) { async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
return this.createForInventoryItems({ return this.createForInventoryItems({
sale, sale,
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }], items: [
{
inventoryItemId,
soldPriceMinor: sale.soldPriceMinor,
costBasisMinor,
},
],
}); });
}, },
@@ -23,33 +29,57 @@ export function createSaleRepository(db) {
.select({ id: inventoryItems.id, status: inventoryItems.status }) .select({ id: inventoryItems.id, status: inventoryItems.status })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and( .where(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)), and(
eq(products.sellerId, sale.sellerId), inArray(
)); inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(products.sellerId, sale.sellerId),
),
);
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' }; if (ownedInventoryItems.length !== items.length)
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE'); return { status: "NOT_FOUND" };
const unavailableItem = ownedInventoryItems.find(
(item) => item.status !== "ACTIVE",
);
if (unavailableItem) { if (unavailableItem) {
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status }; return {
status: "NOT_ACTIVE",
inventoryStatus: unavailableItem.status,
};
} }
const [createdSale] = await transaction.insert(sales).values(sale).returning(); const [createdSale] = await transaction
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({ .insert(sales)
saleId: createdSale.id, .values(sale)
...item, .returning();
}))).returning(); const createdSaleItems = await transaction
.insert(saleItems)
.values(
items.map((item) => ({
saleId: createdSale.id,
...item,
})),
)
.returning();
const updatedInventoryItems = await transaction const updatedInventoryItems = await transaction
.update(inventoryItems) .update(inventoryItems)
.set({ status: 'SOLD', updatedAt: new Date() }) .set({ status: "SOLD", updatedAt: new Date() })
.where(and( .where(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)), and(
eq(inventoryItems.status, 'ACTIVE'), inArray(
)) inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(inventoryItems.status, "ACTIVE"),
),
)
.returning(); .returning();
return { return {
status: 'CREATED', status: "CREATED",
sale: createdSale, sale: createdSale,
saleItems: createdSaleItems, saleItems: createdSaleItems,
saleItem: createdSaleItems[0], saleItem: createdSaleItems[0],
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
}) })
.from(sales) .from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id)) .innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(sales.sellerId, sellerId)) .where(eq(sales.sellerId, sellerId))
.orderBy(desc(sales.saleDate), desc(sales.createdAt)); .orderBy(desc(sales.saleDate), desc(sales.createdAt));
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
productNames: [], productNames: [],
}; };
current.saleItems.push(row.saleItem); current.saleItems.push(row.saleItem);
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus }); current.inventoryItems.push({
id: row.saleItem.inventoryItemId,
status: row.inventoryStatus,
});
current.productNames.push(row.productName); current.productNames.push(row.productName);
grouped.set(row.sale.id, current); grouped.set(row.sale.id, current);
} }
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
...sale, ...sale,
saleItem: sale.saleItems[0], saleItem: sale.saleItems[0],
inventoryItem: sale.inventoryItems[0], inventoryItem: sale.inventoryItems[0],
productName: sale.productNames.join(', '), productName: sale.productNames.join(", "),
})); }));
}, },
async voidForSeller({ saleId, sellerId, actorUserId, reason }) { async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const saleRecords = await transaction const saleRecords = await transaction
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status }) .select({
sale: sales,
saleItem: saleItems,
inventoryStatus: inventoryItems.status,
})
.from(sales) .from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id)) .innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId))); .where(
and(
eq(sales.id, saleId),
eq(sales.sellerId, sellerId),
eq(products.sellerId, sellerId),
),
);
if (!saleRecords.length) return { status: 'NOT_FOUND' }; if (!saleRecords.length) return { status: "NOT_FOUND" };
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' }; if (saleRecords[0].sale.status === "VOIDED")
return { status: "ALREADY_VOIDED" };
const saleRecord = saleRecords[0]; const saleRecord = saleRecords[0];
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId); const inventoryItemIds = saleRecords.map(
({ saleItem }) => saleItem.inventoryItemId,
);
const [updatedSale] = await transaction const [updatedSale] = await transaction
.update(sales) .update(sales)
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() }) .set({
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED'))) status: "VOIDED",
voidReason: reason,
voidedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
.returning(); .returning();
const [updatedInventoryItem] = await transaction const [updatedInventoryItem] = await transaction
.update(inventoryItems) .update(inventoryItems)
.set({ status: 'ACTIVE', updatedAt: new Date() }) .set({ status: "ACTIVE", updatedAt: new Date() })
.where(and( .where(
inArray(inventoryItems.id, inventoryItemIds), and(
eq(inventoryItems.status, 'SOLD'), inArray(inventoryItems.id, inventoryItemIds),
)) eq(inventoryItems.status, "SOLD"),
),
)
.returning(); .returning();
await transaction.insert(securityAuditEvents).values({ await transaction.insert(securityAuditEvents).values({
userId: actorUserId, userId: actorUserId,
event: 'SALE_VOIDED', event: "SALE_VOIDED",
metadata: { saleId, inventoryItemIds, reason }, metadata: { saleId, inventoryItemIds, reason },
}); });
return { return {
status: 'VOIDED', status: "VOIDED",
sale: updatedSale, sale: updatedSale,
saleItems: saleRecords.map(({ saleItem }) => saleItem), saleItems: saleRecords.map(({ saleItem }) => saleItem),
saleItem: saleRecord.saleItem, saleItem: saleRecord.saleItem,
@@ -1,47 +1,64 @@
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale'; import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale'; import {
createMultiItemSaleSchema,
createSaleSchema,
voidSaleSchema,
} from "@pokemon-inventory/validation/sale";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
async function defaultAuthenticateAdmin() { async function defaultAuthenticateAdmin() {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
} }
export async function registerAdminSaleRoutes(app, options) { export async function registerAdminSaleRoutes(app, options) {
const { const {
authenticateAdmin = defaultAuthenticateAdmin, authenticateAdmin = defaultAuthenticateAdmin,
authorizeUnsafeRequest, authorizeUnsafeRequest,
csrfCookieName = 'pokemon_inventory_csrf', csrfCookieName = "pokemon_inventory_csrf",
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
saleService, saleService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) { if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
} }
request.admin = admin; request.admin = admin;
}); });
app.post('/sales', async (request, reply) => { app.post("/sales", async (request, reply) => {
await authorizeUnsafeRequest?.( await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName], request.cookies[sessionCookieName],
request.headers['x-csrf-token'], request.headers["x-csrf-token"],
); );
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema; const schema = Array.isArray(request.body?.items)
? createMultiItemSaleSchema
: createSaleSchema;
const result = schema.safeParse(request.body); const result = schema.safeParse(request.body);
if (!result.success) { if (!result.success) {
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
return reply.code(201).send({ data: toAdminSaleDto(sale) }); return reply.code(201).send({ data: toAdminSaleDto(sale) });
}); });
app.get('/sales', async (request) => ({ app.get("/sales", async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto), data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
toAdminSaleDto,
),
})); }));
app.post('/sales/:id/void', async (request, reply) => { app.post("/sales/:id/void", async (request, reply) => {
await authorizeUnsafeRequest?.( await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName], request.cookies[sessionCookieName],
request.headers['x-csrf-token'], request.headers["x-csrf-token"],
); );
const result = voidSaleSchema.safeParse(request.body); const result = voidSaleSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
@@ -1,35 +1,62 @@
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js'; import {
import { createApplicationError } from '../../../shared/errors/application-error.js'; calculateNetSaleProceeds,
createSale,
validateSaleItem,
} from "../domain/sale.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createSaleService({ saleRepository }) { export function createSaleService({ saleRepository }) {
return { return {
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) { async create({
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }]; sellerId,
inventoryItemId,
costBasisMinor,
items,
...input
}) {
const saleItems = items ?? [
{
inventoryItemId,
soldPriceMinor: input.soldPriceMinor,
costBasisMinor,
},
];
const totalSoldPriceMinor = items const totalSoldPriceMinor = items
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0) ? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
: input.soldPriceMinor; : input.soldPriceMinor;
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor }); const sale = createSale({
sellerId,
...input,
soldPriceMinor: totalSoldPriceMinor,
});
const validatedItems = saleItems.map((item) => ({ const validatedItems = saleItems.map((item) => ({
inventoryItemId: item.inventoryItemId, inventoryItemId: item.inventoryItemId,
soldPriceMinor: validateSaleItem(item).soldPriceMinor, soldPriceMinor: validateSaleItem(item).soldPriceMinor,
costBasisMinor: validateSaleItem(item).costBasisMinor, costBasisMinor: validateSaleItem(item).costBasisMinor,
})); }));
const result = items const result = items
? await saleRepository.createForInventoryItems({ sale, items: validatedItems }) ? await saleRepository.createForInventoryItems({
sale,
items: validatedItems,
})
: await saleRepository.createForInventoryItem({ : await saleRepository.createForInventoryItem({
sale, sale,
inventoryItemId, inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor, costBasisMinor: validatedItems[0].costBasisMinor,
}); });
if (result.status === 'NOT_FOUND') { if (result.status === "NOT_FOUND") {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
} }
if (result.status === 'NOT_ACTIVE') { if (result.status === "NOT_ACTIVE") {
throw createApplicationError( throw createApplicationError(
'INVENTORY_NOT_AVAILABLE_FOR_SALE', "INVENTORY_NOT_AVAILABLE_FOR_SALE",
'Inventory item is not available for sale.', "Inventory item is not available for sale.",
409, 409,
); );
} }
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
}, },
async void({ saleId, sellerId, actorUserId, reason }) { async void({ saleId, sellerId, actorUserId, reason }) {
if (typeof reason !== 'string' || reason.trim().length < 3) { if (typeof reason !== "string" || reason.trim().length < 3) {
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400); throw createApplicationError(
"SALE_VOID_REASON_REQUIRED",
"A void reason is required.",
400,
);
} }
const result = await saleRepository.voidForSeller({ const result = await saleRepository.voidForSeller({
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
actorUserId, actorUserId,
reason: reason.trim(), reason: reason.trim(),
}); });
if (result.status === 'NOT_FOUND') { if (result.status === "NOT_FOUND") {
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404); throw createApplicationError(
"SALE_NOT_FOUND",
"Sale was not found.",
404,
);
} }
if (result.status === 'ALREADY_VOIDED') { if (result.status === "ALREADY_VOIDED") {
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409); throw createApplicationError(
"SALE_ALREADY_VOIDED",
"Sale is already voided.",
409,
);
} }
return withFinancialSummary(result); return withFinancialSummary(result);
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
} }
function withFinancialSummary(result) { function withFinancialSummary(result) {
const netProceedsMinor = calculateNetSaleProceeds(result.sale); const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const saleItems = result.saleItems ?? [result.saleItem]; const saleItems = result.saleItems ?? [result.saleItem];
return { return {
...result, ...result,
netProceedsMinor, netProceedsMinor,
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0), profitMinor:
netProceedsMinor -
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
}; };
} }
@@ -1,5 +1,5 @@
function requiredString(value, fieldName) { function requiredString(value, fieldName) {
if (typeof value !== 'string') { if (typeof value !== "string") {
throw new Error(`${fieldName} must be a string.`); throw new Error(`${fieldName} must be a string.`);
} }
@@ -7,16 +7,16 @@ function requiredString(value, fieldName) {
} }
export function normalizeSearchQuery(value) { export function normalizeSearchQuery(value) {
return requiredString(value, 'Search query') return requiredString(value, "Search query")
.trim() .trim()
.normalize('NFKC') .normalize("NFKC")
.replace(/[^\p{L}\p{N}\s]+/gu, ' ') .replace(/[^\p{L}\p{N}\s]+/gu, " ")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.toLowerCase(); .toLowerCase();
} }
export function createSearchQuery({ export function createSearchQuery({
query = '', query = "",
category, category,
conditionId, conditionId,
minPriceMinor, minPriceMinor,
@@ -24,54 +24,68 @@ export function createSearchQuery({
page = 1, page = 1,
limit = 20, limit = 20,
includeSold = false, includeSold = false,
sort = 'recent', sort = "recent",
}) { }) {
const text = normalizeSearchQuery(query); const text = normalizeSearchQuery(query);
if (!Number.isInteger(page) || page < 1) { if (!Number.isInteger(page) || page < 1) {
throw new Error('page must be >= 1'); throw new Error("page must be >= 1");
} }
if (!Number.isInteger(limit) || limit < 1 || limit > 100) { if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('limit must be between 1 and 100'); throw new Error("limit must be between 1 and 100");
}
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) {
throw new Error('minPriceMinor must be a non-negative integer');
}
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
throw new Error('maxPriceMinor must be a non-negative integer');
} }
if ( if (
typeof minPriceMinor !== 'undefined' && typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
minPriceMinor > maxPriceMinor
) { ) {
throw new Error('minPriceMinor must be <= maxPriceMinor'); throw new Error("minPriceMinor must be a non-negative integer");
} }
if (!['recent', 'price_low', 'price_high'].includes(sort)) { if (
throw new Error('sort must be recent, price_low, or price_high'); typeof maxPriceMinor !== "undefined" &&
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
) {
throw new Error("maxPriceMinor must be a non-negative integer");
}
if (
typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== "undefined" &&
minPriceMinor > maxPriceMinor
) {
throw new Error("minPriceMinor must be <= maxPriceMinor");
}
if (!["recent", "price_low", "price_high"].includes(sort)) {
throw new Error("sort must be recent, price_low, or price_high");
} }
return { return {
text, text,
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}), ...(typeof category === "string" && category.trim().length > 0
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}), ? { category: category.trim() }
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}), : {}),
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}), ...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() }
: {}),
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page, page,
limit, limit,
includeSold, includeSold,
...(sort !== 'recent' ? { sort } : {}), ...(sort !== "recent" ? { sort } : {}),
}; };
} }
export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) { export function buildSearchFilters({
includeSold = false,
status = "AVAILABLE",
sort = ["recently_added"],
}) {
return { return {
status: includeSold ? 'ALL' : status, status: includeSold ? "ALL" : status,
includeSold, includeSold,
sort, sort,
}; };
@@ -1,13 +1,17 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function coerceNumber(value, fieldName, fallback) { function coerceNumber(value, fieldName, fallback) {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === "") {
return fallback; return fallback;
} }
const parsed = Number.parseInt(String(value), 10); const parsed = Number.parseInt(String(value), 10);
if (Number.isNaN(parsed) || String(parsed) !== String(value)) { if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400); throw createApplicationError(
"INVALID_SEARCH_FILTER",
`${fieldName} must be an integer.`,
400,
);
} }
return parsed; return parsed;
} }
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
export async function registerPublicSearchRoutes(app, options) { export async function registerPublicSearchRoutes(app, options) {
const { searchService } = options; const { searchService } = options;
app.get('/public/search', async (request) => { app.get("/public/search", async (request) => {
if (!searchService || typeof searchService.searchPublic !== 'function') { if (!searchService || typeof searchService.searchPublic !== "function") {
throw createApplicationError( throw createApplicationError(
'SEARCH_SERVICE_UNAVAILABLE', "SEARCH_SERVICE_UNAVAILABLE",
'Public search is currently unavailable.', "Public search is currently unavailable.",
503, 503,
); );
} }
@@ -28,12 +32,22 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query, query: request.query.query,
category: request.query.category, category: request.query.category,
conditionId: request.query.conditionId, conditionId: request.query.conditionId,
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined), minPriceMinor: coerceNumber(
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined), request.query.minPriceMinor,
page: coerceNumber(request.query.page, 'page', 1), "minPriceMinor",
limit: coerceNumber(request.query.limit, 'limit', 20), undefined,
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true, ),
sort: request.query.sort ?? 'recent', maxPriceMinor: coerceNumber(
request.query.maxPriceMinor,
"maxPriceMinor",
undefined,
),
page: coerceNumber(request.query.page, "page", 1),
limit: coerceNumber(request.query.limit, "limit", 20),
includeSold:
request.query.includeSold === "true" ||
request.query.includeSold === true,
sort: request.query.sort ?? "recent",
}; };
return { data: await searchService.searchPublic(filters) }; return { data: await searchService.searchPublic(filters) };
@@ -1,23 +1,26 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js'; import { createAcquisitionService } from "../../src/modules/acquisitions/services/acquisition-service.js";
test('creates an owned acquisition and calculates cost basis', async () => { test("creates an owned acquisition and calculates cost basis", async () => {
let received; let received;
const service = createAcquisitionService({ const service = createAcquisitionService({
acquisitionRepository: { acquisitionRepository: {
async createForSeller(input) { async createForSeller(input) {
received = input; received = input;
return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } }; return {
status: "CREATED",
acquisition: { id: "acquisition-1", ...input.acquisition },
};
}, },
}, },
}); });
const result = await service.create({ const result = await service.create({
sellerId: 'seller-1', sellerId: "seller-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
shippingMinor: 150, shippingMinor: 150,
taxMinor: 75, taxMinor: 75,
@@ -25,17 +28,27 @@ test('creates an owned acquisition and calculates cost basis', async () => {
estimatedTradeValueMinor: 200, estimatedTradeValueMinor: 200,
}); });
assert.equal(received.sellerId, 'seller-1'); assert.equal(received.sellerId, "seller-1");
assert.equal(result.costBasisMinor, 1075); assert.equal(result.costBasisMinor, 1075);
}); });
test('rejects acquisition for an inventory item outside the seller account', async () => { test("rejects acquisition for an inventory item outside the seller account", async () => {
const service = createAcquisitionService({ const service = createAcquisitionService({
acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } }, acquisitionRepository: {
async createForSeller() {
return { status: "NOT_FOUND" };
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }), () =>
(error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404, service.create({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
method: "PURCHASE",
costMinor: 100,
}),
(error) => error.code === "INVENTORY_NOT_FOUND" && error.statusCode === 404,
); );
}); });
@@ -1,20 +1,59 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js'; import { createDashboardService } from "../../src/modules/dashboard/services/dashboard-service.js";
test('aggregates seller dashboard counts and financial totals', async () => { test("aggregates seller dashboard counts and financial totals", async () => {
const service = createDashboardService({ const service = createDashboardService({
productService: { async listBySellerId() { return [{ id: 'product-1' }]; } }, productService: {
inventoryItemService: { async listBySellerId() { return [{ id: 'item-1', status: 'ACTIVE' }, { id: 'item-2', status: 'SOLD' }]; } }, async listBySellerId() {
listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } }, return [{ id: "product-1" }];
saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } }, },
acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } }, },
pricingRepository: { async listLatestValuationsForSeller() { return [{ inventoryItemId: 'item-1', valueMinor: 2800, calculatedAt: new Date() }]; } }, inventoryItemService: {
pricingRefreshJobRepository: { async countFailedForSeller() { return 2; } }, async listBySellerId() {
return [
{ id: "item-1", status: "ACTIVE" },
{ id: "item-2", status: "SOLD" },
];
},
},
listingService: {
async list() {
return [{ status: "AVAILABLE", askingPriceMinor: 2500, imageCount: 1 }];
},
},
saleService: {
async list() {
return [
{ status: "COMPLETED", netProceedsMinor: 2200, profitMinor: 700 },
];
},
},
acquisitionService: {
async list() {
return [{ costBasisMinor: 1500 }];
},
},
pricingRepository: {
async listLatestValuationsForSeller() {
return [
{
inventoryItemId: "item-1",
valueMinor: 2800,
calculatedAt: new Date(),
},
];
},
},
pricingRefreshJobRepository: {
async countFailedForSeller() {
return 2;
},
},
}); });
const dashboard = await service.get({ sellerId: 'seller-1' }); const dashboard = await service.get({ sellerId: "seller-1" });
assert.deepEqual(dashboard.counts, { assert.deepEqual(dashboard.counts, {
products: 1, products: 1,
inventory: 2, inventory: 2,
@@ -1,38 +1,87 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js'; import {
import { createFixturePricingProvider } from '../../src/modules/pricing/providers/fixture-pricing-provider.js'; createPricingProvider,
createPricingProviderRegistry,
} from "../../src/modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../../src/modules/pricing/providers/http-pricing-provider.js";
test('registers pricing providers behind a validated adapter contract', async () => { test("registers pricing providers behind a validated adapter contract", async () => {
const provider = createPricingProvider({ const provider = createPricingProvider({
id: 'example', id: "example",
async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; }, async observe() {
return [{ priceMinor: 2500, sourceIdentifier: "example-1" }];
},
}); });
const registry = createPricingProviderRegistry([provider]); const registry = createPricingProviderRegistry([provider]);
assert.equal(registry.get('example'), provider); assert.equal(registry.get("example"), provider);
assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]); assert.deepEqual(await registry.get("example").observe({}), [
{ priceMinor: 2500, sourceIdentifier: "example-1" },
]);
assert.deepEqual(registry.list(), [provider]); assert.deepEqual(registry.list(), [provider]);
}); });
test('rejects malformed provider adapters and responses', async () => { test("rejects malformed provider adapters and responses", async () => {
assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/); assert.throws(
const provider = createPricingProvider({ id: 'broken', async observe() { return null; } }); () => createPricingProvider({ id: "broken" }),
await assert.rejects(() => provider.observe({}), /must return an observation array/); /observe function is required/,
);
const provider = createPricingProvider({
id: "broken",
async observe() {
return null;
},
});
await assert.rejects(
() => provider.observe({}),
/must return an observation array/,
);
}); });
test('fixture provider returns deterministic normalized observations', async () => { test("fixture provider returns deterministic normalized observations", async () => {
const [observation] = await createFixturePricingProvider().observe({ const [observation] = await createFixturePricingProvider().observe({
productId: 'product-1', inventoryItemId: 'item-1', conditionId: 'condition-1', productId: "product-1",
inventoryItemId: "item-1",
conditionId: "condition-1",
}); });
assert.deepEqual(observation, { assert.deepEqual(observation, {
productId: 'product-1', productId: "product-1",
inventoryItemId: 'item-1', inventoryItemId: "item-1",
conditionId: 'condition-1', conditionId: "condition-1",
priceMinor: 2500, priceMinor: 2500,
currency: 'USD', currency: "USD",
sourceIdentifier: 'fixture-item-1', sourceIdentifier: "fixture-item-1",
qualityScore: 50, qualityScore: 50,
}); });
}); });
test("http provider sends mapping context and normalizes its response", async () => {
let request;
const provider = createHttpPricingProvider({
id: "http-provider",
endpoint: "https://provider.example/prices",
token: "test-token",
async fetchImplementation(url, options) {
request = { url: String(url), options };
return {
ok: true,
async json() {
return { observations: [{ priceMinor: 2500 }] };
},
};
},
});
assert.deepEqual(
await provider.observe({
externalIdentifier: "catalog-1",
searchQuery: "Charizard",
}),
[{ priceMinor: 2500 }],
);
assert.match(request.url, /externalIdentifier=catalog-1/);
assert.equal(request.options.headers.authorization, "Bearer test-token");
});
@@ -1,77 +1,137 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js'; import { createPricingService } from "../../src/modules/pricing/services/pricing-service.js";
test('records an owned pricing observation and calculates a valuation', async () => { test("records an owned pricing observation and calculates a valuation", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: { pricingRepository: {
async addObservationForSeller({ observation, sellerId }) { async addObservationForSeller({ observation, sellerId }) {
assert.equal(sellerId, 'seller-1'); assert.equal(sellerId, "seller-1");
return { status: 'CREATED', observation }; return { status: "CREATED", observation };
}, },
async markOutliersForSeller() { async markOutliersForSeller() {
return { status: 'UPDATED' }; return { status: "UPDATED" };
}, },
async calculateForSeller() { async calculateForSeller() {
return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] }; return {
status: "CALCULATED",
valuation: { valueMinor: 2500 },
observations: [],
};
}, },
}, },
}); });
const observation = await service.addObservation({ const observation = await service.addObservation({
sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1', sellerId: "seller-1",
providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1', productId: "product-1",
inventoryItemId: "inventory-1",
providerId: "manual",
priceMinor: 2500,
sourceIdentifier: "source-1",
conditionId: "condition-1",
}); });
assert.equal(observation.priceMinor, 2500); assert.equal(observation.priceMinor, 2500);
const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' }); const valuation = await service.calculate({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
});
assert.equal(valuation.valuation.valueMinor, 2500); assert.equal(valuation.valuation.valueMinor, 2500);
}); });
test('requires a reason for an audited market-value override', async () => { test("requires a reason for an audited market-value override", async () => {
const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } }); const service = createPricingService({
pricingRepository: {
async createOverride() {
throw new Error("should not run");
},
},
});
await assert.rejects( await assert.rejects(
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }), () =>
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400, service.override({
sellerId: "seller-1",
actorUserId: "user-1",
inventoryItemId: "inventory-1",
valueMinor: 2000,
reason: "x",
}),
(error) =>
error.code === "OVERRIDE_REASON_REQUIRED" && error.statusCode === 400,
); );
}); });
test('fails clearly when a requested pricing provider is unavailable', async () => { test("fails clearly when a requested pricing provider is unavailable", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingProviderRegistry: { get() { return null; } }, pricingProviderRegistry: {
get() {
return null;
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }), () =>
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, service.refresh({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
providerId: "missing",
}),
(error) =>
error.code === "PRICING_PROVIDER_UNAVAILABLE" && error.statusCode === 503,
); );
}); });
test('enqueues a seller-scoped pricing refresh and reads its status', async () => { test("enqueues a seller-scoped pricing refresh and reads its status", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingRefreshJobRepository: { pricingRefreshJobRepository: {
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; }, async create(input) {
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; }, return { id: "job-1", status: "PENDING", ...input };
},
async findByIdForSeller(id, sellerId) {
return { id, sellerId, status: "COMPLETED" };
},
},
providerMappingRepository: {
async findActiveForSeller() {
return { providerId: "mapped-provider" };
},
}, },
providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } },
}); });
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' }); const job = await service.enqueueRefresh({
assert.equal(job.status, 'PENDING'); sellerId: "seller-1",
assert.equal(job.providerId, 'mapped-provider'); inventoryItemId: "item-1",
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED'); providerId: "provider-1",
productId: "product-1",
conditionId: "condition-1",
});
assert.equal(job.status, "PENDING");
assert.equal(job.providerId, "mapped-provider");
assert.equal(
(await service.getJob({ sellerId: "seller-1", jobId: job.id })).status,
"COMPLETED",
);
}); });
test('lists and retries seller-scoped failed pricing jobs', async () => { test("lists and retries seller-scoped failed pricing jobs", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingRefreshJobRepository: { pricingRefreshJobRepository: {
async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; }, async listForSeller() {
async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; }, return [{ id: "job-1", status: "FAILED" }];
},
async retryForSeller() {
return { id: "job-1", status: "PENDING" };
},
}, },
}); });
assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1); assert.equal((await service.listJobs({ sellerId: "seller-1" })).length, 1);
assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING'); assert.equal(
(await service.retryJob({ sellerId: "seller-1", jobId: "job-1" })).status,
"PENDING",
);
}); });
@@ -1,21 +1,44 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js'; import { createProviderMappingService } from "../../src/modules/pricing/services/provider-mapping-service.js";
test('creates and disables seller-owned provider mappings', async () => { test("creates and disables seller-owned provider mappings", async () => {
const calls = []; const calls = [];
const service = createProviderMappingService({ const service = createProviderMappingService({
providerMappingRepository: { providerMappingRepository: {
async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; }, async createForSeller(input, sellerId) {
async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; }, calls.push(["create", input, sellerId]);
async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; }, return { status: "CREATED", mapping: { id: "mapping-1", ...input } };
},
async listForSeller() {
return [{ id: "mapping-1", status: "ACTIVE" }];
},
async disableForSeller() {
return {
status: "DISABLED",
mapping: { id: "mapping-1", status: "DISABLED" },
};
},
}, },
}); });
const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }); const created = await service.create({
assert.equal(created.id, 'mapping-1'); sellerId: "seller-1",
assert.equal(calls[0][2], 'seller-1'); productId: "product-1",
assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1); providerId: "fixture",
assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED'); mappingType: "PRODUCT",
externalIdentifier: "ext-1",
});
assert.equal(created.id, "mapping-1");
assert.equal(calls[0][2], "seller-1");
assert.equal(
(await service.list({ sellerId: "seller-1", productId: "product-1" }))
.length,
1,
);
assert.equal(
(await service.disable({ sellerId: "seller-1", id: "mapping-1" })).status,
"DISABLED",
);
}); });
+75 -46
View File
@@ -1,29 +1,29 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createSaleService } from '../../src/modules/sales/services/sale-service.js'; import { createSaleService } from "../../src/modules/sales/services/sale-service.js";
function validInput(overrides = {}) { function validInput(overrides = {}) {
return { return {
sellerId: 'seller-1', sellerId: "seller-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
costBasisMinor: 1200, costBasisMinor: 1200,
...overrides, ...overrides,
}; };
} }
test('creates a sale through the repository with validated money values', async () => { test("creates a sale through the repository with validated money values", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async createForInventoryItem(input) { async createForInventoryItem(input) {
received = input; received = input;
return { return {
status: 'CREATED', status: "CREATED",
sale: { sale: {
id: 'sale-1', id: "sale-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
platformFeesMinor: 100, platformFeesMinor: 100,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
@@ -38,9 +38,9 @@ test('creates a sale through the repository with validated money values', async
}); });
assert.deepEqual(await service.create(validInput()), { assert.deepEqual(await service.create(validInput()), {
status: 'CREATED', status: "CREATED",
sale: { sale: {
id: 'sale-1', id: "sale-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
platformFeesMinor: 100, platformFeesMinor: 100,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
@@ -52,85 +52,105 @@ test('creates a sale through the repository with validated money values', async
netProceedsMinor: 2399, netProceedsMinor: 2399,
profitMinor: 1199, profitMinor: 1199,
}); });
assert.equal(received.sale.sellerId, 'seller-1'); assert.equal(received.sale.sellerId, "seller-1");
assert.equal(received.sale.soldPriceMinor, 2499); assert.equal(received.sale.soldPriceMinor, 2499);
assert.equal(received.costBasisMinor, 1200); assert.equal(received.costBasisMinor, 1200);
}); });
test('lists seller sales with derived net proceeds and profit', async () => { test("lists seller sales with derived net proceeds and profit", async () => {
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1'); assert.equal(sellerId, "seller-1");
return [{ return [
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 }, {
saleItem: { costBasisMinor: 1500 }, sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
inventoryItem: { id: 'inventory-1', status: 'SOLD' }, saleItem: { costBasisMinor: 1500 },
}]; inventoryItem: { id: "inventory-1", status: "SOLD" },
},
];
}, },
}, },
}); });
const [sale] = await service.list({ sellerId: 'seller-1' }); const [sale] = await service.list({ sellerId: "seller-1" });
assert.equal(sale.netProceedsMinor, 2800); assert.equal(sale.netProceedsMinor, 2800);
assert.equal(sale.profitMinor, 1300); assert.equal(sale.profitMinor, 1300);
}); });
test('voids an owned sale with a required reason', async () => { test("voids an owned sale with a required reason", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async voidForSeller(input) { async voidForSeller(input) {
received = input; received = input;
return { return {
status: 'VOIDED', status: "VOIDED",
sale: { id: 'sale-1', soldPriceMinor: 3000 }, sale: { id: "sale-1", soldPriceMinor: 3000 },
saleItem: { costBasisMinor: 1500 }, saleItem: { costBasisMinor: 1500 },
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' }, inventoryItem: { id: "inventory-1", status: "ACTIVE" },
}; };
}, },
}, },
}); });
const result = await service.void({ const result = await service.void({
saleId: 'sale-1', saleId: "sale-1",
sellerId: 'seller-1', sellerId: "seller-1",
actorUserId: 'user-1', actorUserId: "user-1",
reason: ' Duplicate sale ', reason: " Duplicate sale ",
}); });
assert.equal(received.reason, 'Duplicate sale'); assert.equal(received.reason, "Duplicate sale");
assert.equal(result.inventoryItem.status, 'ACTIVE'); assert.equal(result.inventoryItem.status, "ACTIVE");
await assert.rejects( await assert.rejects(
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }), () =>
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400, service.void({
saleId: "sale-1",
sellerId: "seller-1",
actorUserId: "user-1",
reason: "x",
}),
(error) =>
error.code === "SALE_VOID_REASON_REQUIRED" && error.statusCode === 400,
); );
}); });
test('creates one sale containing multiple inventory items', async () => { test("creates one sale containing multiple inventory items", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async createForInventoryItems(input) { async createForInventoryItems(input) {
received = input; received = input;
return { return {
status: 'CREATED', status: "CREATED",
sale: { id: 'sale-2', soldPriceMinor: 3000 }, sale: { id: "sale-2", soldPriceMinor: 3000 },
saleItems: input.items, saleItems: input.items,
saleItem: input.items[0], saleItem: input.items[0],
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })), inventoryItems: input.items.map(({ inventoryItemId }) => ({
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' }, id: inventoryItemId,
status: "SOLD",
})),
inventoryItem: { id: input.items[0].inventoryItemId, status: "SOLD" },
}; };
}, },
}, },
}); });
const result = await service.create({ const result = await service.create({
sellerId: 'seller-1', sellerId: "seller-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
items: [ items: [
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 }, {
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 }, inventoryItemId: "inventory-1",
soldPriceMinor: 1200,
costBasisMinor: 500,
},
{
inventoryItemId: "inventory-2",
soldPriceMinor: 1800,
costBasisMinor: 700,
},
], ],
}); });
@@ -139,18 +159,27 @@ test('creates one sale containing multiple inventory items', async () => {
assert.equal(result.profitMinor, 1800); assert.equal(result.profitMinor, 1800);
}); });
test('maps missing and already-sold inventory to application errors', async () => { test("maps missing and already-sold inventory to application errors", async () => {
for (const result of [ for (const result of [
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 }, { status: "NOT_FOUND", code: "INVENTORY_NOT_FOUND", statusCode: 404 },
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 }, {
status: "NOT_ACTIVE",
code: "INVENTORY_NOT_AVAILABLE_FOR_SALE",
statusCode: 409,
},
]) { ]) {
const service = createSaleService({ const service = createSaleService({
saleRepository: { async createForInventoryItem() { return result; } }, saleRepository: {
async createForInventoryItem() {
return result;
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.create(validInput()), () => service.create(validInput()),
(error) => error.code === result.code && error.statusCode === result.statusCode, (error) =>
error.code === result.code && error.statusCode === result.statusCode,
); );
} }
}); });
@@ -1,17 +1,17 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
calculateAcquisitionCostBasis, calculateAcquisitionCostBasis,
createAcquisition, createAcquisition,
} from '../../src/modules/acquisitions/domain/acquisition.js'; } from "../../src/modules/acquisitions/domain/acquisition.js";
import { import {
calculateMedianPriceMinor, calculateMedianPriceMinor,
createPricingObservation, createPricingObservation,
identifyOutlierPrices, identifyOutlierPrices,
} from '../../src/modules/pricing/domain/pricing-observation.js'; } from "../../src/modules/pricing/domain/pricing-observation.js";
test('calculates acquisition cost basis from all cost components', () => { test("calculates acquisition cost basis from all cost components", () => {
const total = calculateAcquisitionCostBasis({ const total = calculateAcquisitionCostBasis({
costMinor: 1000, costMinor: 1000,
shippingMinor: 150, shippingMinor: 150,
@@ -23,43 +23,48 @@ test('calculates acquisition cost basis from all cost components', () => {
assert.equal(total, 1075); assert.equal(total, 1075);
}); });
test('rejects negative acquisition values and missing identity fields', () => { test("rejects negative acquisition values and missing identity fields", () => {
assert.throws( assert.throws(
() => createAcquisition({ () =>
inventoryItemId: 'inventory-item-id', createAcquisition({
method: 'PURCHASE', inventoryItemId: "inventory-item-id",
costMinor: -1, method: "PURCHASE",
}), costMinor: -1,
}),
/must be a non-negative integer minor unit/, /must be a non-negative integer minor unit/,
); );
assert.throws( assert.throws(
() => createAcquisition({ () =>
inventoryItemId: '', createAcquisition({
method: 'PURCHASE', inventoryItemId: "",
costMinor: 1000, method: "PURCHASE",
}), costMinor: 1000,
}),
/Inventory item ID is required/, /Inventory item ID is required/,
); );
}); });
test('creates pricing observations and calculates a median observed price', () => { test("creates pricing observations and calculates a median observed price", () => {
const observation = createPricingObservation({ const observation = createPricingObservation({
productId: 'product-id', productId: "product-id",
inventoryItemId: 'inventory-item-id', inventoryItemId: "inventory-item-id",
providerId: 'ebay', providerId: "ebay",
priceMinor: 2499, priceMinor: 2499,
currency: 'USD', currency: "USD",
sourceIdentifier: 'listing-1', sourceIdentifier: "listing-1",
conditionId: 'condition-id', conditionId: "condition-id",
}); });
assert.equal(observation.currency, 'USD'); assert.equal(observation.currency, "USD");
assert.equal(observation.priceMinor, 2499); assert.equal(observation.priceMinor, 2499);
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000); assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
}); });
test('identifies extreme pricing observations without discarding normal values', () => { test("identifies extreme pricing observations without discarding normal values", () => {
assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]); assert.deepEqual(
[...identifyOutlierPrices([1000, 1050, 1100, 5000])],
[5000],
);
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0); assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
}); });
+49 -42
View File
@@ -1,76 +1,83 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
calculateNetSaleProceeds, calculateNetSaleProceeds,
createSale, createSale,
createSaleItem, createSaleItem,
} from '../../src/modules/sales/domain/sale.js'; } from "../../src/modules/sales/domain/sale.js";
test('creates a valid sale record and calculates net proceeds after fees', () => { test("creates a valid sale record and calculates net proceeds after fees", () => {
const sale = createSale({ const sale = createSale({
sellerId: 'seller-id', sellerId: "seller-id",
saleDate: '2026-09-08', saleDate: "2026-09-08",
saleLocation: 'eBay', saleLocation: "eBay",
soldPriceMinor: 4999, soldPriceMinor: 4999,
currency: 'USD', currency: "USD",
platformFeesMinor: 300, platformFeesMinor: 300,
paymentProcessingFeesMinor: 120, paymentProcessingFeesMinor: 120,
shippingCostMinor: 50, shippingCostMinor: 50,
taxesMinor: 90, taxesMinor: 90,
otherFeesMinor: 40, otherFeesMinor: 40,
buyerName: 'Alex', buyerName: "Alex",
buyerContact: 'alex@example.com', buyerContact: "alex@example.com",
}); });
assert.equal(sale.soldPriceMinor, 4999); assert.equal(sale.soldPriceMinor, 4999);
assert.equal(sale.currency, 'USD'); assert.equal(sale.currency, "USD");
assert.equal(calculateNetSaleProceeds({ assert.equal(
soldPriceMinor: 4999, calculateNetSaleProceeds({
platformFeesMinor: 300, soldPriceMinor: 4999,
paymentProcessingFeesMinor: 120, platformFeesMinor: 300,
shippingCostMinor: 50, paymentProcessingFeesMinor: 120,
taxesMinor: 90, shippingCostMinor: 50,
otherFeesMinor: 40, taxesMinor: 90,
}), 4399); otherFeesMinor: 40,
}),
4399,
);
}); });
test('rejects invalid sales and negative fee values', () => { test("rejects invalid sales and negative fee values", () => {
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-09-08', sellerId: "seller-id",
soldPriceMinor: 0, saleDate: "2026-09-08",
}), soldPriceMinor: 0,
}),
/must be a positive integer minor unit/, /must be a positive integer minor unit/,
); );
assert.throws( assert.throws(
() => createSaleItem({ () =>
saleId: 'sale-id', createSaleItem({
inventoryItemId: 'inventory-item-id', saleId: "sale-id",
soldPriceMinor: 2000, inventoryItemId: "inventory-item-id",
costBasisMinor: -1, soldPriceMinor: 2000,
}), costBasisMinor: -1,
}),
/must be a non-negative integer minor unit/, /must be a non-negative integer minor unit/,
); );
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-02-30', sellerId: "seller-id",
soldPriceMinor: 2000, saleDate: "2026-02-30",
}), soldPriceMinor: 2000,
}),
/must be a valid ISO date/, /must be a valid ISO date/,
); );
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-09-08', sellerId: "seller-id",
soldPriceMinor: 2000, saleDate: "2026-09-08",
currency: 'US dollars', soldPriceMinor: 2000,
}), currency: "US dollars",
}),
/must be a three-letter ISO currency code/, /must be a three-letter ISO currency code/,
); );
}); });
+40 -20
View File
@@ -1,16 +1,16 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
buildSearchFilters, buildSearchFilters,
createSearchQuery, createSearchQuery,
normalizeSearchQuery, normalizeSearchQuery,
} from '../../src/modules/search/domain/search-query.js'; } from "../../src/modules/search/domain/search-query.js";
test('normalizes a public search query and keeps filter values within bounds', () => { test("normalizes a public search query and keeps filter values within bounds", () => {
const query = createSearchQuery({ const query = createSearchQuery({
query: ' Charizard ex ', query: " Charizard ex ",
category: 'Cards', category: "Cards",
minPriceMinor: 1000, minPriceMinor: 1000,
maxPriceMinor: 5000, maxPriceMinor: 5000,
page: 1, page: 1,
@@ -18,8 +18,8 @@ test('normalizes a public search query and keeps filter values within bounds', (
includeSold: false, includeSold: false,
}); });
assert.equal(query.text, 'charizard ex'); assert.equal(query.text, "charizard ex");
assert.equal(query.category, 'Cards'); assert.equal(query.category, "Cards");
assert.equal(query.minPriceMinor, 1000); assert.equal(query.minPriceMinor, 1000);
assert.equal(query.maxPriceMinor, 5000); assert.equal(query.maxPriceMinor, 5000);
assert.equal(query.page, 1); assert.equal(query.page, 1);
@@ -27,25 +27,45 @@ test('normalizes a public search query and keeps filter values within bounds', (
assert.equal(query.includeSold, false); assert.equal(query.includeSold, false);
}); });
test('rejects invalid pagination and price boundaries', () => { test("rejects invalid pagination and price boundaries", () => {
assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/); assert.throws(
assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/); () => createSearchQuery({ query: "abc", page: 0, limit: 20 }),
assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/); /page must be >= 1/,
);
assert.throws(
() =>
createSearchQuery({
query: "abc",
minPriceMinor: 5000,
maxPriceMinor: 1000,
}),
/minPriceMinor must be <= maxPriceMinor/,
);
assert.throws(
() => createSearchQuery({ query: "abc", limit: 0 }),
/limit must be between 1 and 100/,
);
}); });
test('builds a public query filter set that hides sold items by default', () => { test("builds a public query filter set that hides sold items by default", () => {
const filters = buildSearchFilters({ includeSold: false }); const filters = buildSearchFilters({ includeSold: false });
assert.equal(filters.status, 'AVAILABLE'); assert.equal(filters.status, "AVAILABLE");
assert.equal(filters.includeSold, false); assert.equal(filters.includeSold, false);
assert.deepEqual(filters.sort, ['recently_added']); assert.deepEqual(filters.sort, ["recently_added"]);
}); });
test('accepts only the supported public search sort values', () => { test("accepts only the supported public search sort values", () => {
assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low'); assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/); assert.throws(
() => createSearchQuery({ sort: "random" }),
/sort must be recent/,
);
}); });
test('normalizes whitespace and punctuation in free-text search terms', () => { test("normalizes whitespace and punctuation in free-text search terms", () => {
assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo'); assert.equal(
normalizeSearchQuery(" Pikachu #199 / holo "),
"pikachu 199 holo",
);
}); });
+22 -18
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
return buildApp({ return buildApp({
@@ -9,7 +9,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
authorizeUnsafeRequest, authorizeUnsafeRequest,
acquisitionService: { acquisitionService: {
async create(input) { async create(input) {
return { id: 'acquisition-1', ...input, costBasisMinor: 1075 }; return { id: "acquisition-1", ...input, costBasisMinor: 1075 };
}, },
async list() { async list() {
return []; return [];
@@ -18,21 +18,23 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
}); });
} }
test('creates an acquisition for the authenticated seller', async (testContext) => { test("creates an acquisition for the authenticated seller", async (testContext) => {
let csrfChecked = false; let csrfChecked = false;
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecked = true; }, authorizeUnsafeRequest: async () => {
csrfChecked = true;
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/acquisitions', url: "/api/v1/admin/acquisitions",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { payload: {
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
}, },
}); });
@@ -42,16 +44,18 @@ test('creates an acquisition for the authenticated seller', async (testContext)
assert.equal(csrfChecked, true); assert.equal(csrfChecked, true);
}); });
test('rejects invalid acquisition requests', async (testContext) => { test("rejects invalid acquisition requests", async (testContext) => {
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/acquisitions', url: "/api/v1/admin/acquisitions",
payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 }, payload: { inventoryItemId: "not-a-uuid", method: "", costMinor: -1 },
}); });
assert.equal(response.statusCode, 400); assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'VALIDATION_ERROR'); assert.equal(response.json().error.code, "VALIDATION_ERROR");
}); });
+26 -8
View File
@@ -1,16 +1,34 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('returns a seller-scoped dashboard', async (testContext) => { test("returns a seller-scoped dashboard", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } }, dashboardService: {
async get({ sellerId }) {
assert.equal(sellerId, "seller-1");
return {
counts: {},
financials: {},
recentSales: [],
recentAcquisitions: [],
};
},
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' }); const response = await app.inject({
method: "GET",
url: "/api/v1/admin/dashboard",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }); assert.deepEqual(response.json().data, {
counts: {},
financials: {},
recentSales: [],
recentAcquisitions: [],
});
}); });
+113 -46
View File
@@ -1,25 +1,54 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('accepts a protected pricing observation and valuation request', async (testContext) => { test("accepts a protected pricing observation and valuation request", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
pricingService: { pricingService: {
async addObservation(input) { return { id: 'observation-1', ...input }; }, async addObservation(input) {
async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; }, 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()); testContext.after(() => app.close());
const observationResponse = await app.inject({ const observationResponse = await app.inject({
method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' }, method: "POST",
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' }, 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",
}); });
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(observationResponse.statusCode, 201);
assert.equal(valuationResponse.statusCode, 200); assert.equal(valuationResponse.statusCode, 200);
@@ -27,59 +56,74 @@ test('accepts a protected pricing observation and valuation request', async (tes
assert.equal(csrfChecks, 2); assert.equal(csrfChecks, 2);
}); });
test('accepts a CSRF-protected market-value override', async (testContext) => { test("accepts a CSRF-protected market-value override", async (testContext) => {
let received; let received;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } }, pricingService: {
async override(input) {
received = input;
return { id: "override-1", ...input };
},
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override', url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { valueMinor: 2600, reason: 'Verified recent sale' }, payload: { valueMinor: 2600, reason: "Verified recent sale" },
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(received.actorUserId, 'user-1'); assert.equal(received.actorUserId, "user-1");
assert.equal(response.json().data.valueMinor, 2600); assert.equal(response.json().data.valueMinor, 2600);
}); });
test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => { test("enqueues a pricing refresh and exposes seller-scoped job status", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { pricingService: {
async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; }, async enqueueRefresh(input) {
async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; }, return { id: "job-1", status: "PENDING", createdAt: input.sellerId };
},
async getJob(input) {
assert.equal(input.sellerId, "seller-1");
return { id: input.jobId, status: "COMPLETED", error: null };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/inventory-1/pricing/refresh', url: "/api/v1/admin/inventory/inventory-1/pricing/refresh",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { providerId: 'missing' }, payload: { providerId: "missing" },
}); });
assert.equal(response.statusCode, 202); assert.equal(response.statusCode, 202);
assert.equal(response.json().data.status, 'PENDING'); assert.equal(response.json().data.status, "PENDING");
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' }); const jobResponse = await app.inject({
method: "GET",
url: "/api/v1/admin/jobs/job-1",
});
assert.equal(jobResponse.statusCode, 200); assert.equal(jobResponse.statusCode, 200);
assert.equal(jobResponse.json().data.status, 'COMPLETED'); assert.equal(jobResponse.json().data.status, "COMPLETED");
}); });
test('returns a conflict when refresh has no active product mapping', async (testContext) => { test("returns a conflict when refresh has no active product mapping", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { pricingService: {
async enqueueRefresh() { async enqueueRefresh() {
const error = new Error('No active pricing provider mapping exists for this product.'); const error = new Error(
error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND'; "No active pricing provider mapping exists for this product.",
);
error.code = "PRICING_PROVIDER_MAPPING_NOT_FOUND";
error.statusCode = 409; error.statusCode = 409;
throw error; throw error;
}, },
@@ -88,29 +132,52 @@ test('returns a conflict when refresh has no active product mapping', async (tes
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/item-1/pricing/refresh', url: "/api/v1/admin/inventory/item-1/pricing/refresh",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { productId: 'product-1', conditionId: 'condition-1' }, payload: { productId: "product-1", conditionId: "condition-1" },
}); });
assert.equal(response.statusCode, 409); assert.equal(response.statusCode, 409);
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND'); assert.equal(
response.json().error.code,
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
);
}); });
test('lists jobs and retries a failed job with CSRF protection', async (testContext) => { test("lists jobs and retries a failed job with CSRF protection", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
pricingService: { pricingService: {
async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; }, async listJobs() {
async retryJob() { return { id: 'job-1', status: 'PENDING' }; }, return [
{
id: "job-1",
status: "FAILED",
providerId: "fixture",
inventoryItemId: "item-1",
},
];
},
async retryJob() {
return { id: "job-1", status: "PENDING" };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' }); const listResponse = await app.inject({
const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } }); method: "GET",
url: "/api/v1/admin/jobs",
});
const retryResponse = await app.inject({
method: "POST",
url: "/api/v1/admin/jobs/job-1/retry",
headers: { "x-csrf-token": "csrf" },
});
assert.equal(listResponse.statusCode, 200); assert.equal(listResponse.statusCode, 200);
assert.equal(retryResponse.statusCode, 202); assert.equal(retryResponse.statusCode, 202);
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 1);
@@ -1,29 +1,40 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => { test("protects provider mapping writes and scopes mappings to the seller", async (testContext) => {
let received; let received;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
providerMappingService: { providerMappingService: {
async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; }, async create(input) {
async list() { return []; }, received = input;
async disable() { return { id: 'mapping-1', status: 'DISABLED' }; }, return { id: "mapping-1", ...input, status: "ACTIVE" };
},
async list() {
return [];
},
async disable() {
return { id: "mapping-1", status: "DISABLED" };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/products/product-1/provider-mappings', url: "/api/v1/admin/products/product-1/provider-mappings",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }, payload: {
providerId: "fixture",
mappingType: "PRODUCT",
externalIdentifier: "ext-1",
},
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(received.sellerId, 'seller-1'); assert.equal(received.sellerId, "seller-1");
assert.equal(response.json().data.providerId, 'fixture'); assert.equal(response.json().data.providerId, "fixture");
}); });
@@ -1,19 +1,19 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('returns public search results with pagination metadata', async (testContext) => { test("returns public search results with pagination metadata", async (testContext) => {
const app = buildApp({ const app = buildApp({
searchService: { searchService: {
async searchPublic(filters) { async searchPublic(filters) {
assert.equal(filters.page, 2); assert.equal(filters.page, 2);
assert.equal(filters.limit, 5); assert.equal(filters.limit, 5);
assert.equal(filters.includeSold, true); assert.equal(filters.includeSold, true);
assert.equal(filters.sort, 'price_low'); assert.equal(filters.sort, "price_low");
assert.equal(filters.conditionId, 'condition-1'); assert.equal(filters.conditionId, "condition-1");
return { return {
items: [{ id: 'listing-1', status: 'CLOSED' }], items: [{ id: "listing-1", status: "CLOSED" }],
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
}; };
}, },
@@ -22,8 +22,8 @@ test('returns public search results with pagination metadata', async (testContex
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: "GET",
url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1', url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1",
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
@@ -33,44 +33,71 @@ test('returns public search results with pagination metadata', async (testContex
total: 6, total: 6,
totalPages: 2, totalPages: 2,
}); });
assert.equal(response.json().data.items[0].id, 'listing-1'); assert.equal(response.json().data.items[0].id, "listing-1");
}); });
test('rejects malformed numeric search filters', async (testContext) => { test("rejects malformed numeric search filters", async (testContext) => {
const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } }); const app = buildApp({
searchService: {
async searchPublic() {
throw new Error("should not run");
},
},
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: "GET",
url: '/api/v1/public/search?minPriceMinor=12.5', url: "/api/v1/public/search?minPriceMinor=12.5",
}); });
assert.equal(response.statusCode, 400); assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER'); assert.equal(response.json().error.code, "INVALID_SEARCH_FILTER");
}); });
test('returns active public category options', async (testContext) => { test("returns active public category options", async (testContext) => {
const app = buildApp({ const app = buildApp({
catalogLookupRepository: { catalogLookupRepository: {
async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; }, async listCategories() {
return [{ id: "category-1", name: "Trading Cards" }];
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' }); const response = await app.inject({
method: "GET",
url: "/api/v1/public/lookups/categories",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]); assert.deepEqual(response.json().data, [
{ id: "category-1", name: "Trading Cards" },
]);
}); });
test('returns public condition options without category internals', async (testContext) => { test("returns public condition options without category internals", async (testContext) => {
const app = buildApp({ const app = buildApp({
catalogLookupRepository: { catalogLookupRepository: {
async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; }, async listConditions() {
return [
{
id: "condition-1",
name: "Near Mint",
code: "NM",
categoryId: "private-category",
},
];
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' }); const response = await app.inject({
method: "GET",
url: "/api/v1/public/lookups/conditions",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]); assert.deepEqual(response.json().data, [
{ id: "condition-1", name: "Near Mint", code: "NM" },
]);
}); });
+42 -38
View File
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
const saleResult = { const saleResult = {
sale: { sale: {
id: 'sale-1', id: "sale-1",
sellerId: 'seller-1', sellerId: "seller-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
currency: 'USD', currency: "USD",
platformFeesMinor: 0, platformFeesMinor: 0,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
shippingCostMinor: 0, shippingCostMinor: 0,
@@ -17,12 +17,12 @@ const saleResult = {
otherFeesMinor: 0, otherFeesMinor: 0,
}, },
saleItem: { saleItem: {
id: 'sale-item-1', id: "sale-item-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
costBasisMinor: 1200, costBasisMinor: 1200,
}, },
inventoryItem: { id: 'inventory-1', status: 'SOLD' }, inventoryItem: { id: "inventory-1", status: "SOLD" },
}; };
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
@@ -31,70 +31,74 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
authorizeUnsafeRequest, authorizeUnsafeRequest,
saleService: { saleService: {
async create(input) { async create(input) {
assert.equal(input.sellerId, 'seller-1'); assert.equal(input.sellerId, "seller-1");
return saleResult; return saleResult;
}, },
}, },
}); });
} }
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => { test("creates a sale for the authenticated seller with CSRF protection", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { payload: {
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
costBasisMinor: 1200, costBasisMinor: 1200,
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
}, },
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(response.json().data.inventoryItem.status, 'SOLD'); assert.equal(response.json().data.inventoryItem.status, "SOLD");
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 1);
}); });
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => { test("rejects invalid sale requests and unauthenticated requests", async (testContext) => {
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const invalidResponse = await app.inject({ const invalidResponse = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
payload: { payload: {
inventoryItemId: 'not-a-uuid', inventoryItemId: "not-a-uuid",
costBasisMinor: -1, costBasisMinor: -1,
saleDate: 'tomorrow', saleDate: "tomorrow",
soldPriceMinor: 0, soldPriceMinor: 0,
}, },
}); });
assert.equal(invalidResponse.statusCode, 400); assert.equal(invalidResponse.statusCode, 400);
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR'); assert.equal(invalidResponse.json().error.code, "VALIDATION_ERROR");
const unauthenticatedApp = createTestApp(); const unauthenticatedApp = createTestApp();
testContext.after(() => unauthenticatedApp.close()); testContext.after(() => unauthenticatedApp.close());
const unauthenticatedResponse = await unauthenticatedApp.inject({ const unauthenticatedResponse = await unauthenticatedApp.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
payload: {}, payload: {},
}); });
assert.equal(unauthenticatedResponse.statusCode, 401); assert.equal(unauthenticatedResponse.statusCode, 401);
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED'); assert.equal(unauthenticatedResponse.json().error.code, "UNAUTHORIZED");
}); });
test('voids a sale with CSRF protection and actor identity', async (testContext) => { test("voids a sale with CSRF protection and actor identity", async (testContext) => {
let voidInput; let voidInput;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
saleService: { saleService: {
async void(input) { async void(input) {
@@ -106,13 +110,13 @@ test('voids a sale with CSRF protection and actor identity', async (testContext)
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales/sale-1/void', url: "/api/v1/admin/sales/sale-1/void",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { reason: 'Duplicate entry' }, payload: { reason: "Duplicate entry" },
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.equal(voidInput.actorUserId, 'user-1'); assert.equal(voidInput.actorUserId, "user-1");
assert.equal(response.json().data.inventoryItem.status, 'SOLD'); assert.equal(response.json().data.inventoryItem.status, "SOLD");
}); });
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from "node:test";
import { sql } from 'drizzle-orm'; import { sql } from "drizzle-orm";
import { createDatabase } from '../../src/infrastructure/database/client.js'; import { createDatabase } from "../../src/infrastructure/database/client.js";
import { import {
categories, categories,
conditionProfiles, conditionProfiles,
@@ -13,76 +13,103 @@ import {
securityAuditEvents, securityAuditEvents,
sellers, sellers,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from "../../src/infrastructure/database/schema.js";
import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionRepository } from "../../src/modules/acquisitions/repositories/acquisition-repository.js";
import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-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 { createPricingRefreshJobRepository } from "../../src/modules/pricing/repositories/pricing-refresh-job-repository.js";
import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js'; import { createProviderMappingRepository } from "../../src/modules/pricing/repositories/provider-mapping-repository.js";
import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js'; import { createSaleRepository } from "../../src/modules/sales/repositories/sale-repository.js";
const connectionString = process.env.TEST_DATABASE_URL; const connectionString = process.env.TEST_DATABASE_URL;
if (!connectionString) { if (!connectionString) {
throw new Error('TEST_DATABASE_URL is required to run integration tests.'); throw new Error("TEST_DATABASE_URL is required to run integration tests.");
} }
if (!new URL(connectionString).pathname.endsWith('_test')) { if (!new URL(connectionString).pathname.endsWith("_test")) {
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".'); throw new Error(
'TEST_DATABASE_URL must reference a database whose name ends in "_test".',
);
} }
const database = createDatabase(connectionString); const database = createDatabase(connectionString);
const acquisitionRepository = createAcquisitionRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db); const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(
database.db,
);
const providerMappingRepository = createProviderMappingRepository(database.db); const providerMappingRepository = createProviderMappingRepository(database.db);
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
async function createFixture() { async function createFixture() {
const [user] = await database.db.insert(users).values({ const [user] = await database.db
email: 'transactions@example.test', .insert(users)
passwordHash: 'not-a-real-password', .values({
}).returning(); email: "transactions@example.test",
const [seller] = await database.db.insert(sellers).values({ passwordHash: "not-a-real-password",
userId: user.id, })
displayName: 'Transactions Owner', .returning();
}).returning(); const [seller] = await database.db
const [category] = await database.db.insert(categories).values({ .insert(sellers)
name: 'Transactions Cards', .values({
slug: 'transactions-cards', userId: user.id,
}).returning(); displayName: "Transactions Owner",
const [profile] = await database.db.insert(conditionProfiles).values({ })
categoryId: category.id, .returning();
name: 'Card', const [category] = await database.db
}).returning(); .insert(categories)
const [condition] = await database.db.insert(conditions).values({ .values({
conditionProfileId: profile.id, name: "Transactions Cards",
name: 'Near Mint', slug: "transactions-cards",
code: 'NM', })
qualityRank: 1, .returning();
}).returning(); const [profile] = await database.db
const [product] = await database.db.insert(products).values({ .insert(conditionProfiles)
sellerId: seller.id, .values({
categoryId: category.id, categoryId: category.id,
name: 'Integration Card', name: "Card",
slug: 'integration-card', })
}).returning(); .returning();
const inventory = await database.db.insert(inventoryItems).values([ const [condition] = await database.db
{ productId: product.id, conditionId: condition.id }, .insert(conditions)
{ productId: product.id, conditionId: condition.id }, .values({
]).returning(); conditionProfileId: profile.id,
name: "Near Mint",
code: "NM",
qualityRank: 1,
})
.returning();
const [product] = await database.db
.insert(products)
.values({
sellerId: seller.id,
categoryId: category.id,
name: "Integration Card",
slug: "integration-card",
})
.returning();
const inventory = await database.db
.insert(inventoryItems)
.values([
{ productId: product.id, conditionId: condition.id },
{ productId: product.id, conditionId: condition.id },
])
.returning();
return { user, seller, product, condition, inventory }; return { user, seller, product, condition, inventory };
} }
beforeEach(async () => { beforeEach(async () => {
await database.db.execute(sql`TRUNCATE pricing_provider_mappings, 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`); await database.db.execute(
sql`TRUNCATE pricing_provider_mappings, 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 () => { after(async () => {
await database.close(); await database.close();
}); });
test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => { test("persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally", async () => {
const { user, seller, product, condition, inventory } = await createFixture(); const { user, seller, product, condition, inventory } = await createFixture();
const [firstItem, secondItem] = inventory; const [firstItem, secondItem] = inventory;
@@ -90,47 +117,50 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
sellerId: seller.id, sellerId: seller.id,
acquisition: { acquisition: {
inventoryItemId: firstItem.id, inventoryItemId: firstItem.id,
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
currency: 'USD', currency: "USD",
shippingMinor: 100, shippingMinor: 100,
taxMinor: 50, taxMinor: 50,
otherFeesMinor: 0, otherFeesMinor: 0,
estimatedTradeValueMinor: 0, estimatedTradeValueMinor: 0,
}, },
}); });
assert.equal(acquisition.status, 'CREATED'); assert.equal(acquisition.status, "CREATED");
for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) { for (const [item, price] of [
[firstItem, 2500],
[secondItem, 3000],
]) {
const observation = await pricingRepository.addObservationForSeller({ const observation = await pricingRepository.addObservationForSeller({
sellerId: seller.id, sellerId: seller.id,
observation: { observation: {
productId: product.id, productId: product.id,
inventoryItemId: item.id, inventoryItemId: item.id,
providerId: 'integration', providerId: "integration",
priceMinor: price, priceMinor: price,
currency: 'USD', currency: "USD",
sourceIdentifier: `source-${item.id}`, sourceIdentifier: `source-${item.id}`,
conditionId: condition.id, conditionId: condition.id,
}, },
}); });
assert.equal(observation.status, 'CREATED'); assert.equal(observation.status, "CREATED");
} }
const valuation = await pricingRepository.calculateForSeller({ const valuation = await pricingRepository.calculateForSeller({
sellerId: seller.id, sellerId: seller.id,
inventoryItemId: firstItem.id, inventoryItemId: firstItem.id,
}); });
assert.equal(valuation.status, 'CALCULATED'); assert.equal(valuation.status, "CALCULATED");
assert.equal(valuation.valuation.valueMinor, 2500); assert.equal(valuation.valuation.valueMinor, 2500);
assert.equal(valuation.valuation.sourceCount, 1); assert.equal(valuation.valuation.sourceCount, 1);
const sale = await saleRepository.createForInventoryItems({ const sale = await saleRepository.createForInventoryItems({
sale: { sale: {
sellerId: seller.id, sellerId: seller.id,
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 5500, soldPriceMinor: 5500,
currency: 'USD', currency: "USD",
platformFeesMinor: 200, platformFeesMinor: 200,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
shippingCostMinor: 0, shippingCostMinor: 0,
@@ -138,58 +168,92 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
otherFeesMinor: 0, otherFeesMinor: 0,
}, },
items: [ items: [
{ inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 }, {
{ inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 }, inventoryItemId: firstItem.id,
soldPriceMinor: 2500,
costBasisMinor: 1150,
},
{
inventoryItemId: secondItem.id,
soldPriceMinor: 3000,
costBasisMinor: 900,
},
], ],
}); });
assert.equal(sale.status, 'CREATED'); assert.equal(sale.status, "CREATED");
assert.equal(sale.saleItems.length, 2); assert.equal(sale.saleItems.length, 2);
assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']); assert.deepEqual(
sale.inventoryItems.map(({ status }) => status),
["SOLD", "SOLD"],
);
const voided = await saleRepository.voidForSeller({ const voided = await saleRepository.voidForSeller({
saleId: sale.sale.id, saleId: sale.sale.id,
sellerId: seller.id, sellerId: seller.id,
actorUserId: user.id, actorUserId: user.id,
reason: 'Integration correction', reason: "Integration correction",
}); });
assert.equal(voided.status, 'VOIDED'); assert.equal(voided.status, "VOIDED");
assert.equal(voided.sale.status, 'VOIDED'); assert.equal(voided.sale.status, "VOIDED");
assert.equal(voided.saleItems.length, 2); assert.equal(voided.saleItems.length, 2);
const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`); const [auditEvent] = await database.db
.select()
.from(securityAuditEvents)
.where(sql`event = 'SALE_VOIDED'`);
assert.equal(auditEvent.userId, user.id); assert.equal(auditEvent.userId, user.id);
}); });
test('claims and completes a durable pricing refresh job', async () => { test("claims and completes a durable pricing refresh job", async () => {
const { user, seller, product, condition, inventory } = await createFixture(); const { user, seller, product, condition, inventory } = await createFixture();
const [job] = await pricingRefreshJobRepository.create({ const [job] = await pricingRefreshJobRepository
sellerId: seller.id, .create({
inventoryItemId: inventory[0].id, sellerId: seller.id,
productId: product.id, inventoryItemId: inventory[0].id,
conditionId: condition.id, productId: product.id,
providerId: 'integration', conditionId: condition.id,
}).then((created) => [created]); providerId: "integration",
})
.then((created) => [created]);
const claimed = await pricingRefreshJobRepository.claimNext(); const claimed = await pricingRefreshJobRepository.claimNext();
assert.equal(claimed.id, job.id); assert.equal(claimed.id, job.id);
assert.equal(claimed.status, 'PROCESSING'); assert.equal(claimed.status, "PROCESSING");
const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 }); const completed = await pricingRefreshJobRepository.complete(
assert.equal(completed.status, 'COMPLETED'); job.id,
[{ priceMinor: 2500 }],
{ valueMinor: 2500 },
);
assert.equal(completed.status, "COMPLETED");
assert.deepEqual(completed.valuation, { valueMinor: 2500 }); assert.deepEqual(completed.valuation, { valueMinor: 2500 });
assert.equal(user.email, 'transactions@example.test'); assert.equal(user.email, "transactions@example.test");
}); });
test('persists and disables seller-owned provider mappings', async () => { test("persists and disables seller-owned provider mappings", async () => {
const { seller, product } = await createFixture(); const { seller, product } = await createFixture();
const created = await providerMappingRepository.createForSeller({ const created = await providerMappingRepository.createForSeller(
productId: product.id, {
providerId: 'integration', productId: product.id,
mappingType: 'PRODUCT', providerId: "integration",
externalIdentifier: 'catalog-1', mappingType: "PRODUCT",
}, seller.id); externalIdentifier: "catalog-1",
assert.equal(created.status, 'CREATED'); },
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1); seller.id,
const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id); );
assert.equal(disabled.status, 'DISABLED'); assert.equal(created.status, "CREATED");
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0); assert.equal(
(await providerMappingRepository.listForSeller(product.id, seller.id))
.length,
1,
);
const disabled = await providerMappingRepository.disableForSeller(
created.mapping.id,
seller.id,
);
assert.equal(disabled.status, "DISABLED");
assert.equal(
(await providerMappingRepository.listForSeller(product.id, seller.id))
.length,
0,
);
}); });
@@ -1,11 +1,11 @@
import ImageGallery from './ImageGallery.vue'; import ImageGallery from "./ImageGallery.vue";
export default { title: 'Molecules/ImageGallery', component: ImageGallery }; export default { title: "Molecules/ImageGallery", component: ImageGallery };
export const ContainedMuseumStage = { export const ContainedMuseumStage = {
args: { args: {
images: [ images: [
{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, { id: "front", label: "FRONT", alt: "Front of Charizard ex" },
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, { id: "back", label: "BACK", alt: "Back of Charizard ex" },
], ],
}, },
}; };
@@ -13,21 +13,37 @@ export const ContainedMuseumStage = {
export const WithPublicImageUrls = { export const WithPublicImageUrls = {
args: { args: {
images: [ images: [
{ id: 'front', src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', alt: 'Front of Charizard ex' }, {
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, id: "front",
src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E',
alt: "Front of Charizard ex",
},
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
], ],
}, },
}; };
export const BrokenImageFallback = { export const BrokenImageFallback = {
args: { args: {
images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }], images: [
{
id: "broken",
src: "/fixtures/missing-image.webp",
alt: "Unavailable collectible image",
},
],
}, },
}; };
export const LoadingImage = { export const LoadingImage = {
args: { args: {
images: [{ id: 'loading', src: '/fixtures/slow-image.webp', alt: 'Collectible image loading' }], images: [
{
id: "loading",
src: "/fixtures/slow-image.webp",
alt: "Collectible image loading",
},
],
}, },
parameters: { chromatic: { delay: 1000 } }, parameters: { chromatic: { delay: 1000 } },
}; };
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from "vue";
const props = defineProps({ const props = defineProps({
images: { type: Array, required: true }, images: { type: Array, required: true },
@@ -9,7 +9,7 @@ const failedImages = ref(new Set());
const loadingImages = ref(new Set()); const loadingImages = ref(new Set());
function imageKey(image) { function imageKey(image) {
return image?.id ?? ''; return image?.id ?? "";
} }
function markLoading(image) { function markLoading(image) {
@@ -36,12 +36,67 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
<template> <template>
<div class="image-gallery"> <div class="image-gallery">
<div class="image-gallery__stage"> <div class="image-gallery__stage">
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @load="markLoaded(props.images[selectedIndex].id)" @error="markFailed(props.images[selectedIndex].id)" /> <img
<span v-if="loadingImages.has(imageKey(props.images[selectedIndex]))" class="image-gallery__placeholder" role="status" aria-label="Loading collectible image">Loading image...</span> v-if="
<span v-else-if="!((props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id))" class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span> (props.images[selectedIndex]?.src ??
props.images[selectedIndex]?.url) &&
!failedImages.has(props.images[selectedIndex].id)
"
:src="
props.images[selectedIndex].src ?? props.images[selectedIndex].url
"
:alt="
props.images[selectedIndex].altText ?? props.images[selectedIndex].alt
"
@load="markLoaded(props.images[selectedIndex].id)"
@error="markFailed(props.images[selectedIndex].id)"
/>
<span
v-if="loadingImages.has(imageKey(props.images[selectedIndex]))"
class="image-gallery__placeholder"
role="status"
aria-label="Loading collectible image"
>Loading image...</span
>
<span
v-else-if="
!(
(props.images[selectedIndex]?.src ??
props.images[selectedIndex]?.url) &&
!failedImages.has(props.images[selectedIndex].id)
)
"
class="image-gallery__placeholder"
role="img"
:aria-label="
props.images[selectedIndex]?.src
? 'Collectible image unavailable'
: 'No collectible image available'
"
>{{
props.images[selectedIndex]?.src
? "IMAGE UNAVAILABLE"
: (props.images[selectedIndex]?.label ?? "IMAGE")
}}</span
>
</div> </div>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images"> <div
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" :aria-current="index === selectedIndex ? 'true' : undefined" @click="selectedIndex = index; markLoading(image)"> v-if="props.images.length > 1"
class="image-gallery__controls"
aria-label="Gallery images"
>
<button
v-for="(image, index) in props.images"
:key="image.id"
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
type="button"
:aria-label="`Show image ${index + 1}: ${image.alt}`"
:aria-current="index === selectedIndex ? 'true' : undefined"
@click="
selectedIndex = index;
markLoading(image);
"
>
{{ index + 1 }} {{ index + 1 }}
</button> </button>
</div> </div>
@@ -49,5 +104,5 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@use './ImageGallery.scss'; @use "./ImageGallery.scss";
</style> </style>
@@ -1,32 +1,36 @@
import CollectibleDetail from './CollectibleDetail.vue'; import CollectibleDetail from "./CollectibleDetail.vue";
export default { title: 'Organisms/CollectibleDetail', component: CollectibleDetail }; export default {
title: "Organisms/CollectibleDetail",
component: CollectibleDetail,
};
export const ListingDetail = { export const ListingDetail = {
args: { args: {
title: 'Charizard ex #199/165', title: "Charizard ex #199/165",
status: 'AVAILABLE', status: "AVAILABLE",
priceMinor: 11500, priceMinor: 11500,
marketValueMinor: 12000, marketValueMinor: 12000,
condition: 'Near Mint', condition: "Near Mint",
description: 'A clean collector copy with a clear front and back.', description: "A clean collector copy with a clear front and back.",
facts: [ facts: [
{ label: 'Set', value: 'Scarlet & Violet 151' }, { label: "Set", value: "Scarlet & Violet 151" },
{ label: 'Card number', value: '199/165' }, { label: "Card number", value: "199/165" },
{ label: 'Market context', value: '$120 reference' }, { label: "Market context", value: "$120 reference" },
{ label: 'Updated', value: 'Today' }, { label: "Updated", value: "Today" },
], ],
images: [ images: [
{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, { id: "front", label: "FRONT", alt: "Front of Charizard ex" },
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, { id: "back", label: "BACK", alt: "Back of Charizard ex" },
], ],
}, },
}; };
export const ProductWithoutImages = { export const ProductWithoutImages = {
args: { args: {
title: 'Pikachu World Collection', title: "Pikachu World Collection",
description: 'A representative product identity with images still to be added.', description:
facts: [{ label: 'Category', value: 'Collectible merchandise' }], "A representative product identity with images still to be added.",
facts: [{ label: "Category", value: "Collectible merchandise" }],
}, },
}; };
@@ -1,17 +1,17 @@
<script setup> <script setup>
import ConditionBadge from '@/components/atoms/ConditionBadge.vue'; import ConditionBadge from "@/components/atoms/ConditionBadge.vue";
import PriceDisplay from '@/components/atoms/PriceDisplay.vue'; import PriceDisplay from "@/components/atoms/PriceDisplay.vue";
import StatusBadge from '@/components/atoms/StatusBadge.vue'; import StatusBadge from "@/components/atoms/StatusBadge.vue";
import ImageGallery from '@/components/molecules/ImageGallery.vue'; import ImageGallery from "@/components/molecules/ImageGallery.vue";
defineProps({ defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
status: { type: String, default: '' }, status: { type: String, default: "" },
priceMinor: { type: Number, default: null }, priceMinor: { type: Number, default: null },
marketValueMinor: { type: Number, default: null }, marketValueMinor: { type: Number, default: null },
marketValueCurrency: { type: String, default: 'USD' }, marketValueCurrency: { type: String, default: "USD" },
condition: { type: String, default: '' }, condition: { type: String, default: "" },
description: { type: String, default: '' }, description: { type: String, default: "" },
facts: { type: Array, default: () => [] }, facts: { type: Array, default: () => [] },
images: { type: Array, default: () => [] }, images: { type: Array, default: () => [] },
}); });
@@ -29,7 +29,11 @@ defineProps({
<p v-if="description" class="detail-notes">{{ description }}</p> <p v-if="description" class="detail-notes">{{ description }}</p>
<div class="collectible-detail__summary"> <div class="collectible-detail__summary">
<PriceDisplay v-if="priceMinor !== null" :amount-minor="priceMinor" /> <PriceDisplay v-if="priceMinor !== null" :amount-minor="priceMinor" />
<PriceDisplay v-if="marketValueMinor !== null" label="Market value" :amount-minor="marketValueMinor" /> <PriceDisplay
v-if="marketValueMinor !== null"
label="Market value"
:amount-minor="marketValueMinor"
/>
<ConditionBadge v-if="condition" :label="condition" /> <ConditionBadge v-if="condition" :label="condition" />
</div> </div>
<dl v-if="facts.length" class="product-facts"> <dl v-if="facts.length" class="product-facts">
@@ -43,9 +47,33 @@ defineProps({
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.collectible-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-12); align-items: start; } .collectible-detail {
.collectible-detail__identity { display: grid; gap: var(--space-4); padding-top: var(--space-6); } display: grid;
.collectible-detail__identity h1 { margin: 0; } grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
.collectible-detail__summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); } gap: var(--space-12);
@media (max-width: 768px) { .collectible-detail { grid-template-columns: 1fr; gap: var(--space-6); } .collectible-detail__identity { padding-top: 0; } } align-items: start;
}
.collectible-detail__identity {
display: grid;
gap: var(--space-4);
padding-top: var(--space-6);
}
.collectible-detail__identity h1 {
margin: 0;
}
.collectible-detail__summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
}
@media (max-width: 768px) {
.collectible-detail {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.collectible-detail__identity {
padding-top: 0;
}
}
</style> </style>
+45 -10
View File
@@ -1,8 +1,16 @@
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
import { searchCatalog } from '@/services/catalog-service.js'; import { searchCatalog } from "@/services/catalog-service.js";
export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialConditionId = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) { export function useCatalogSearch({
initialQuery = "",
initialCategory = "",
initialConditionId = "",
initialMinPriceMinor,
initialMaxPriceMinor,
initialSort = "recent",
initialPage = 1,
} = {}) {
const query = ref(initialQuery); const query = ref(initialQuery);
const category = ref(initialCategory); const category = ref(initialCategory);
const conditionId = ref(initialConditionId); const conditionId = ref(initialConditionId);
@@ -10,7 +18,10 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
const maxPriceMinor = ref(initialMaxPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor);
const sort = ref(initialSort); const sort = ref(initialSort);
const page = ref(initialPage); const page = ref(initialPage);
const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } }); const result = ref({
items: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
});
const isLoading = ref(false); const isLoading = ref(false);
const error = ref(null); const error = ref(null);
let requestId = 0; let requestId = 0;
@@ -21,7 +32,15 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
error.value = null; error.value = null;
try { try {
const nextResult = await searchCatalog({ query: query.value, category: category.value, conditionId: conditionId.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value }); const nextResult = await searchCatalog({
query: query.value,
category: category.value,
conditionId: conditionId.value,
minPriceMinor: minPriceMinor.value,
maxPriceMinor: maxPriceMinor.value,
sort: sort.value,
page: page.value,
});
if (currentRequestId === requestId) result.value = nextResult; if (currentRequestId === requestId) result.value = nextResult;
} catch (requestError) { } catch (requestError) {
if (currentRequestId === requestId) error.value = requestError; if (currentRequestId === requestId) error.value = requestError;
@@ -30,10 +49,13 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
} }
} }
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort], () => { watch(
if (page.value !== 1) page.value = 1; [query, category, conditionId, minPriceMinor, maxPriceMinor, sort],
else load(); () => {
}); if (page.value !== 1) page.value = 1;
else load();
},
);
watch(page, load); watch(page, load);
function goToPage(nextPage) { function goToPage(nextPage) {
@@ -42,5 +64,18 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
} }
} }
return { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage }; return {
query,
category,
conditionId,
minPriceMinor,
maxPriceMinor,
sort,
page,
result,
isLoading,
error,
load,
goToPage,
};
} }
+10 -9
View File
@@ -1,14 +1,15 @@
import AdminHomePage from './AdminHomePage.vue'; import AdminHomePage from "./AdminHomePage.vue";
export default { title: 'Pages/AdminHomePage', component: AdminHomePage }; export default { title: "Pages/AdminHomePage", component: AdminHomePage };
export const Overview = {}; export const Overview = {};
export const ProviderMappingWorkflow = { export const ProviderMappingWorkflow = {
parameters: { parameters: {
docs: { docs: {
description: { description: {
story: 'The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.', story:
}, "The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.",
}, },
}, },
},
}; };
File diff suppressed because it is too large Load Diff
+124 -37
View File
@@ -1,46 +1,84 @@
<script setup> <script setup>
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from "vue-router";
import { useCatalogSearch } from '@/composables/useCatalogSearch.js'; import { useCatalogSearch } from "@/composables/useCatalogSearch.js";
import { listPublicCategories, listPublicConditions } from '@/services/catalog-service.js'; import {
import ListingCard from '@/components/molecules/ListingCard.vue'; listPublicCategories,
listPublicConditions,
} from "@/services/catalog-service.js";
import ListingCard from "@/components/molecules/ListingCard.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const categories = ref([]); const categories = ref([]);
const conditions = ref([]); const conditions = ref([]);
const { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage } = useCatalogSearch({ const {
initialQuery: typeof route.query.q === 'string' ? route.query.q : '', query,
initialCategory: typeof route.query.category === 'string' ? route.query.category : '', category,
initialConditionId: typeof route.query.conditionId === 'string' ? route.query.conditionId : '', conditionId,
minPriceMinor,
maxPriceMinor,
sort,
page,
result,
isLoading,
error,
load,
goToPage,
} = useCatalogSearch({
initialQuery: typeof route.query.q === "string" ? route.query.q : "",
initialCategory:
typeof route.query.category === "string" ? route.query.category : "",
initialConditionId:
typeof route.query.conditionId === "string" ? route.query.conditionId : "",
initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined, initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined,
initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined,
initialSort: typeof route.query.sort === 'string' ? route.query.sort : 'recent', initialSort:
typeof route.query.sort === "string" ? route.query.sort : "recent",
initialPage: Number.parseInt(route.query.page, 10) || 1, initialPage: Number.parseInt(route.query.page, 10) || 1,
}); });
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
load(), load(),
listPublicCategories().then((items) => { categories.value = items; }).catch(() => {}), listPublicCategories()
listPublicConditions().then((items) => { conditions.value = items; }).catch(() => {}), .then((items) => {
categories.value = items;
})
.catch(() => {}),
listPublicConditions()
.then((items) => {
conditions.value = items;
})
.catch(() => {}),
]); ]);
}); });
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery, nextCategory, nextCondition, nextMin, nextMax, nextSort, nextPage]) => { watch(
router.replace({ [query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
query: { ([
...(nextQuery ? { q: nextQuery } : {}), nextQuery,
...(nextCategory ? { category: nextCategory } : {}), nextCategory,
...(nextCondition ? { conditionId: nextCondition } : {}), nextCondition,
...(typeof nextMin === 'number' ? { min: String(nextMin) } : {}), nextMin,
...(typeof nextMax === 'number' ? { max: String(nextMax) } : {}), nextMax,
...(nextSort !== 'recent' ? { sort: nextSort } : {}), nextSort,
...(nextPage > 1 ? { page: String(nextPage) } : {}), nextPage,
}, ]) => {
}); router.replace({
}); query: {
...(nextQuery ? { q: nextQuery } : {}),
...(nextCategory ? { category: nextCategory } : {}),
...(nextCondition ? { conditionId: nextCondition } : {}),
...(typeof nextMin === "number" ? { min: String(nextMin) } : {}),
...(typeof nextMax === "number" ? { max: String(nextMax) } : {}),
...(nextSort !== "recent" ? { sort: nextSort } : {}),
...(nextPage > 1 ? { page: String(nextPage) } : {}),
},
});
},
);
</script> </script>
<template> <template>
@@ -48,17 +86,52 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
<header class="catalog-header"> <header class="catalog-header">
<p class="catalog-kicker">Pokemon inventory</p> <p class="catalog-kicker">Pokemon inventory</p>
<h1>Find the next favorite.</h1> <h1>Find the next favorite.</h1>
<p class="catalog-intro">Browse available cards, collectibles, and carefully cataloged pieces.</p> <p class="catalog-intro">
Browse available cards, collectibles, and carefully cataloged pieces.
</p>
<label class="search-field"> <label class="search-field">
<span>Search the catalog</span> <span>Search the catalog</span>
<input v-model="query" type="search" placeholder="Try Charizard, 151, plush..." /> <input
v-model="query"
type="search"
placeholder="Try Charizard, 151, plush..."
/>
</label> </label>
<div class="catalog-filters"> <div class="catalog-filters">
<label>Category <select v-model="category"><option value="">All categories</option><option v-for="item in categories" :key="item.id" :value="item.id">{{ item.name }}</option></select></label> <label
<label>Condition <select v-model="conditionId"><option value="">All conditions</option><option v-for="item in conditions" :key="item.id" :value="item.id">{{ item.name }} ({{ item.code }})</option></select></label> >Category
<label>Min price (cents) <input v-model.number="minPriceMinor" type="number" min="0" /></label> <select v-model="category">
<label>Max price (cents) <input v-model.number="maxPriceMinor" type="number" min="0" /></label> <option value="">All categories</option>
<label>Sort <select v-model="sort"><option value="recent">Recently added</option><option value="price_low">Price: low to high</option><option value="price_high">Price: high to low</option></select></label> <option v-for="item in categories" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select></label
>
<label
>Condition
<select v-model="conditionId">
<option value="">All conditions</option>
<option v-for="item in conditions" :key="item.id" :value="item.id">
{{ item.name }} ({{ item.code }})
</option>
</select></label
>
<label
>Min price (cents)
<input v-model.number="minPriceMinor" type="number" min="0"
/></label>
<label
>Max price (cents)
<input v-model.number="maxPriceMinor" type="number" min="0"
/></label>
<label
>Sort
<select v-model="sort">
<option value="recent">Recently added</option>
<option value="price_low">Price: low to high</option>
<option value="price_high">Price: high to low</option>
</select></label
>
</div> </div>
</header> </header>
@@ -69,13 +142,25 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
</div> </div>
<p v-if="isLoading" class="catalog-state">Searching the catalog...</p> <p v-if="isLoading" class="catalog-state">Searching the catalog...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p> <p v-else-if="error" class="catalog-state catalog-state--error">
<p v-else-if="result.items.length === 0" class="catalog-state">No relevant matches found.</p> {{ error.message }}
</p>
<p v-else-if="result.items.length === 0" class="catalog-state">
No relevant matches found.
</p>
<div v-else class="listing-grid"> <div v-else class="listing-grid">
<ListingCard v-for="item in result.items" :key="item.id" :listing="item" /> <ListingCard
v-for="item in result.items"
:key="item.id"
:listing="item"
/>
</div> </div>
<nav v-if="result.pagination.totalPages > 1" class="pagination" aria-label="Catalog pages"> <nav
v-if="result.pagination.totalPages > 1"
class="pagination"
aria-label="Catalog pages"
>
<button <button
type="button" type="button"
:disabled="page <= 1 || isLoading" :disabled="page <= 1 || isLoading"
@@ -83,7 +168,9 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
> >
Previous Previous
</button> </button>
<span aria-live="polite">Page {{ page }} of {{ result.pagination.totalPages }}</span> <span aria-live="polite"
>Page {{ page }} of {{ result.pagination.totalPages }}</span
>
<button <button
type="button" type="button"
:disabled="page >= result.pagination.totalPages || isLoading" :disabled="page >= result.pagination.totalPages || isLoading"
+15 -7
View File
@@ -1,9 +1,9 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref } from "vue";
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from "vue-router";
import { getPublicListing } from '@/services/catalog-service.js'; import { getPublicListing } from "@/services/catalog-service.js";
import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue'; import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue";
const route = useRoute(); const route = useRoute();
const listing = ref(null); const listing = ref(null);
@@ -23,9 +23,13 @@ onMounted(async () => {
<template> <template>
<main class="detail-page"> <main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink> <RouterLink class="detail-back" :to="{ name: 'catalog' }"
>Back to catalog</RouterLink
>
<p v-if="isLoading" class="catalog-state">Loading listing...</p> <p v-if="isLoading" class="catalog-state">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p> <p v-else-if="error" class="catalog-state catalog-state--error">
{{ error.message }}
</p>
<CollectibleDetail <CollectibleDetail
v-else-if="listing" v-else-if="listing"
:title="listing.productName" :title="listing.productName"
@@ -33,7 +37,11 @@ onMounted(async () => {
:price-minor="listing.askingPriceMinor" :price-minor="listing.askingPriceMinor"
:market-value-minor="listing.marketValueMinor" :market-value-minor="listing.marketValueMinor"
:market-value-currency="listing.marketValueCurrency" :market-value-currency="listing.marketValueCurrency"
:condition="listing.conditionName ? `${listing.conditionName} (${listing.conditionCode})` : listing.conditionId" :condition="
listing.conditionName
? `${listing.conditionName} (${listing.conditionCode})`
: listing.conditionId
"
:description="listing.notes" :description="listing.notes"
:images="listing.images" :images="listing.images"
/> />
@@ -1,24 +1,28 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include', credentials: "include",
...options, ...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Acquisition request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Acquisition request failed.");
return payload?.data; return payload?.data;
} }
export function listAdminAcquisitions() { export function listAdminAcquisitions() {
return request('/admin/acquisitions'); return request("/admin/acquisitions");
} }
export function createAdminAcquisition(input, csrfToken) { export function createAdminAcquisition(input, csrfToken) {
return request('/admin/acquisitions', { return request("/admin/acquisitions", {
method: 'POST', method: "POST",
headers: { 'x-csrf-token': csrfToken }, headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
@@ -1,8 +1,11 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function getAdminDashboard() { export async function getAdminDashboard() {
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, { credentials: 'include' }); const response = await fetch(`${apiBaseUrl}/admin/dashboard`, {
credentials: "include",
});
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Dashboard request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Dashboard request failed.");
return payload?.data; return payload?.data;
} }
+11 -7
View File
@@ -1,23 +1,27 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include', credentials: "include",
...options, ...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Job request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Job request failed.");
return payload?.data; return payload?.data;
} }
export function listAdminPricingJobs() { export function listAdminPricingJobs() {
return request('/admin/jobs'); return request("/admin/jobs");
} }
export function retryAdminPricingJob(jobId, csrfToken) { export function retryAdminPricingJob(jobId, csrfToken) {
return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, { return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, {
method: 'POST', method: "POST",
headers: { 'x-csrf-token': csrfToken }, headers: { "x-csrf-token": csrfToken },
}); });
} }
+34 -8
View File
@@ -1,14 +1,40 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) { async function request(path, options = {}) {
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }; const headers = {
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers }); ...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
};
const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: "include",
...options,
headers,
});
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Listing request failed.");
return payload?.data; return payload?.data;
} }
export function listAdminListings() { return request('/admin/listings'); } export function listAdminListings() {
export function createAdminListing(input, csrfToken) { return request('/admin/listings', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(input) }); } return request("/admin/listings");
export function publishAdminListing(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/publish`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); } }
export function markListingPending(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/pending`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); } export function createAdminListing(input, csrfToken) {
return request("/admin/listings", {
method: "POST",
headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
});
}
export function publishAdminListing(id, csrfToken) {
return request(`/admin/listings/${encodeURIComponent(id)}/publish`, {
method: "POST",
headers: { "x-csrf-token": csrfToken },
});
}
export function markListingPending(id, csrfToken) {
return request(`/admin/listings/${encodeURIComponent(id)}/pending`, {
method: "POST",
headers: { "x-csrf-token": csrfToken },
});
}
+34 -21
View File
@@ -1,45 +1,58 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include', credentials: "include",
...options, ...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Pricing request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Pricing request failed.");
return payload?.data; return payload?.data;
} }
export function addAdminPricingObservation(input, csrfToken) { export function addAdminPricingObservation(input, csrfToken) {
return request('/admin/pricing/observations', { return request("/admin/pricing/observations", {
method: 'POST', method: "POST",
headers: { 'x-csrf-token': csrfToken }, headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function calculateAdminValuation(inventoryItemId, csrfToken) { export function calculateAdminValuation(inventoryItemId, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`, { return request(
method: 'POST', `/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`,
headers: { 'x-csrf-token': csrfToken }, {
}); method: "POST",
headers: { "x-csrf-token": csrfToken },
},
);
} }
export function overrideAdminValuation(inventoryItemId, input, csrfToken) { export function overrideAdminValuation(inventoryItemId, input, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`, { return request(
method: 'POST', `/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`,
headers: { 'x-csrf-token': csrfToken }, {
body: JSON.stringify(input), method: "POST",
}); headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
} }
export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) { export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`, { return request(
method: 'POST', `/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`,
headers: { 'x-csrf-token': csrfToken }, {
body: JSON.stringify(input), method: "POST",
}); headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
} }
export function getAdminPricingJob(jobId) { export function getAdminPricingJob(jobId) {
@@ -1,24 +1,35 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
async function request(path, options = {}) { async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
credentials: 'include', credentials: "include",
...options, ...options,
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, headers: {
...(options.body ? { "content-type": "application/json" } : {}),
...options.headers,
},
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Provider mapping request failed.'); if (!response.ok)
throw new Error(
payload?.error?.message ?? "Provider mapping request failed.",
);
return payload?.data; return payload?.data;
} }
export function listAdminProviderMappings(productId) { export function listAdminProviderMappings(productId) {
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`); return request(
`/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
);
} }
export function createAdminProviderMapping(productId, input, csrfToken) { export function createAdminProviderMapping(productId, input, csrfToken) {
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`, { return request(
method: 'POST', `/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
headers: { 'x-csrf-token': csrfToken }, {
body: JSON.stringify(input), method: "POST",
}); headers: { "x-csrf-token": csrfToken },
body: JSON.stringify(input),
},
);
} }
+25 -17
View File
@@ -1,38 +1,46 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function createAdminSale(input, csrfToken) { export async function createAdminSale(input, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales`, { const response = await fetch(`${apiBaseUrl}/admin/sales`, {
method: 'POST', method: "POST",
credentials: 'include', credentials: "include",
headers: { headers: {
'content-type': 'application/json', "content-type": "application/json",
'x-csrf-token': csrfToken, "x-csrf-token": csrfToken,
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale request failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale request failed.");
return payload?.data; return payload?.data;
} }
export async function listAdminSales() { export async function listAdminSales() {
const response = await fetch(`${apiBaseUrl}/admin/sales`, { credentials: 'include' }); const response = await fetch(`${apiBaseUrl}/admin/sales`, {
credentials: "include",
});
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale history is unavailable.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale history is unavailable.");
return payload?.data ?? []; return payload?.data ?? [];
} }
export async function voidAdminSale(id, reason, csrfToken) { export async function voidAdminSale(id, reason, csrfToken) {
const response = await fetch(`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`, { const response = await fetch(
method: 'POST', `${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`,
credentials: 'include', {
headers: { method: "POST",
'content-type': 'application/json', credentials: "include",
'x-csrf-token': csrfToken, headers: {
"content-type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify({ reason }),
}, },
body: JSON.stringify({ reason }), );
});
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale correction failed.'); if (!response.ok)
throw new Error(payload?.error?.message ?? "Sale correction failed.");
return payload?.data; return payload?.data;
} }
+42 -15
View File
@@ -1,16 +1,31 @@
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
export async function searchCatalog({ query = '', category = '', conditionId = '', minPriceMinor, maxPriceMinor, sort = 'recent', page = 1, limit = 20 } = {}) { export async function searchCatalog({
const params = new URLSearchParams({ query, page: String(page), limit: String(limit) }); query = "",
if (sort !== 'recent') params.set('sort', sort); category = "",
if (category) params.set('category', category); conditionId = "",
if (conditionId) params.set('conditionId', conditionId); minPriceMinor,
if (typeof minPriceMinor === 'number') params.set('minPriceMinor', String(minPriceMinor)); maxPriceMinor,
if (typeof maxPriceMinor === 'number') params.set('maxPriceMinor', String(maxPriceMinor)); sort = "recent",
page = 1,
limit = 20,
} = {}) {
const params = new URLSearchParams({
query,
page: String(page),
limit: String(limit),
});
if (sort !== "recent") params.set("sort", sort);
if (category) params.set("category", category);
if (conditionId) params.set("conditionId", conditionId);
if (typeof minPriceMinor === "number")
params.set("minPriceMinor", String(minPriceMinor));
if (typeof maxPriceMinor === "number")
params.set("maxPriceMinor", String(maxPriceMinor));
const response = await fetch(`${apiBaseUrl}/public/search?${params}`); const response = await fetch(`${apiBaseUrl}/public/search?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Catalog search is unavailable.'); throw new Error("Catalog search is unavailable.");
} }
const payload = await response.json(); const payload = await response.json();
@@ -19,21 +34,27 @@ export async function searchCatalog({ query = '', category = '', conditionId = '
export async function listPublicCategories() { export async function listPublicCategories() {
const response = await fetch(`${apiBaseUrl}/public/lookups/categories`); const response = await fetch(`${apiBaseUrl}/public/lookups/categories`);
if (!response.ok) throw new Error('Categories are unavailable.'); if (!response.ok) throw new Error("Categories are unavailable.");
return (await response.json()).data; return (await response.json()).data;
} }
export async function listPublicConditions() { export async function listPublicConditions() {
const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`); const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`);
if (!response.ok) throw new Error('Conditions are unavailable.'); if (!response.ok) throw new Error("Conditions are unavailable.");
return (await response.json()).data; return (await response.json()).data;
} }
export async function getPublicListing(id) { export async function getPublicListing(id) {
const response = await fetch(`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`); const response = await fetch(
`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`,
);
if (!response.ok) { if (!response.ok) {
throw new Error(response.status === 404 ? 'Listing not found.' : 'Listing is unavailable.'); throw new Error(
response.status === 404
? "Listing not found."
: "Listing is unavailable.",
);
} }
const payload = await response.json(); const payload = await response.json();
@@ -41,10 +62,16 @@ export async function getPublicListing(id) {
} }
export async function getPublicProduct(slug) { export async function getPublicProduct(slug) {
const response = await fetch(`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`); const response = await fetch(
`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`,
);
if (!response.ok) { if (!response.ok) {
throw new Error(response.status === 404 ? 'Product not found.' : 'Product is unavailable.'); throw new Error(
response.status === 404
? "Product not found."
: "Product is unavailable.",
);
} }
const payload = await response.json(); const payload = await response.json();
+157 -79
View File
@@ -1,103 +1,181 @@
import { expect, test } from '@playwright/test'; import { expect, test } from "@playwright/test";
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'); const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
test('signs in and publishes a listing after adding an inventory image', async ({ page }) => { test("signs in and publishes a listing after adding an inventory image", async ({
page,
}) => {
const email = process.env.INITIAL_ADMIN_EMAIL; const email = process.env.INITIAL_ADMIN_EMAIL;
const password = process.env.INITIAL_ADMIN_PASSWORD; const password = process.env.INITIAL_ADMIN_PASSWORD;
test.skip(!email || !password, 'Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.'); test.skip(
!email || !password,
"Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.",
);
await page.goto('/login'); await page.goto("/login");
await page.getByLabel('Email').fill(email); await page.getByLabel("Email").fill(email);
await page.getByLabel('Password').fill(password); await page.getByLabel("Password").fill(password);
await page.getByRole('button', { name: 'Sign in' }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/admin$/); await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible(); await expect(
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0); page.getByRole("heading", { name: "Collector workspace" }),
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible(); ).toBeVisible();
await expect(page.getByText('Active inventory')).toBeVisible(); await expect(
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Missing images' }).getByRole('link')).toHaveAttribute('href', '#inventory-title'); page.getByRole("status", { name: "Loading inventory..." }),
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Provider failures' }).getByRole('link')).toHaveAttribute('href', '#pricing-title'); ).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "At a glance" }),
).toBeVisible();
await expect(page.getByText("Active inventory")).toBeVisible();
await expect(
page
.locator(".dashboard-summary__grid div")
.filter({ hasText: "Missing images" })
.getByRole("link"),
).toHaveAttribute("href", "#inventory-title");
await expect(
page
.locator(".dashboard-summary__grid div")
.filter({ hasText: "Provider failures" })
.getByRole("link"),
).toHaveAttribute("href", "#pricing-title");
const category = page.locator('#product-category option').nth(1); const category = page.locator("#product-category option").nth(1);
await expect(category).toBeAttached(); await expect(category).toBeAttached();
const productName = `Browser test collectible ${Date.now()}`; const productName = `Browser test collectible ${Date.now()}`;
await page.getByLabel('Name').fill(productName); await page.getByLabel("Name").fill(productName);
await page.getByLabel('Category').selectOption({ index: 1 }); await page.getByLabel("Category").selectOption({ index: 1 });
await page.getByRole('button', { name: 'Create product' }).click(); await page.getByRole("button", { name: "Create product" }).click();
await expect(page.locator('#inventory-product option').nth(1)).toBeAttached(); await expect(page.locator("#inventory-product option").nth(1)).toBeAttached();
const createdProductId = await page.locator('#inventory-product').locator('option', { hasText: productName }).getAttribute('value'); const createdProductId = await page
await page.locator('#mapping-product').selectOption(createdProductId); .locator("#inventory-product")
await page.getByLabel('Provider ID').fill('fixture-provider'); .locator("option", { hasText: productName })
await page.getByLabel('External identifier').fill('fixture-product-1'); .getAttribute("value");
await page.getByRole('button', { name: 'Save provider mapping' }).click(); await page.locator("#mapping-product").selectOption(createdProductId);
await expect(page.getByRole('region', { name: 'Active provider mappings' })).toContainText('fixture-provider'); await page.getByLabel("Provider ID").fill("fixture-provider");
await page.locator('#inventory-product').selectOption(createdProductId); await page.getByLabel("External identifier").fill("fixture-product-1");
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 }); await page.getByRole("button", { name: "Save provider mapping" }).click();
await page.getByRole('button', { name: 'Create copy' }).click(); await expect(
page.getByRole("region", { name: "Active provider mappings" }),
).toContainText("fixture-provider");
await page.locator("#inventory-product").selectOption(createdProductId);
await page
.getByLabel("Condition", { exact: true })
.selectOption({ index: 1 });
await page.getByRole("button", { name: "Create copy" }).click();
const createdInventoryRow = page.locator(`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`).last(); const createdInventoryRow = page
const createdInventoryId = await createdInventoryRow.locator('a').getAttribute('href').then((href) => href.split('/').at(-2)); .locator(
const inventoryLink = page.locator(`a[href="/admin/inventory/${createdInventoryId}/images"]`); `section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`,
)
.last();
const createdInventoryId = await createdInventoryRow
.locator("a")
.getAttribute("href")
.then((href) => href.split("/").at(-2));
const inventoryLink = page.locator(
`a[href="/admin/inventory/${createdInventoryId}/images"]`,
);
await expect(inventoryLink).toBeVisible(); await expect(inventoryLink).toBeVisible();
await page.locator('#acquisition-inventory').selectOption(createdInventoryId); await page.locator("#acquisition-inventory").selectOption(createdInventoryId);
await page.getByLabel('Method').fill('PURCHASE'); await page.getByLabel("Method").fill("PURCHASE");
await page.getByLabel('Acquisition date').fill('2026-09-09'); await page.getByLabel("Acquisition date").fill("2026-09-09");
await page.getByLabel('Cost (cents)').fill('1000'); await page.getByLabel("Cost (cents)").fill("1000");
await page.getByLabel('Shipping (cents)').fill('100'); await page.getByLabel("Shipping (cents)").fill("100");
await page.getByLabel('Tax (cents)').fill('50'); await page.getByLabel("Tax (cents)").fill("50");
await page.getByRole('button', { name: 'Record acquisition' }).click(); await page.getByRole("button", { name: "Record acquisition" }).click();
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50'); await expect(
await page.locator('#pricing-inventory').selectOption(createdInventoryId); page.locator('section[aria-labelledby="acquisitions-title"] li').first(),
await page.getByLabel('Source identifier').fill('browser-price-1'); ).toContainText("Basis $11.50");
await page.getByLabel('Observed price (cents)').fill('2500'); await page.locator("#pricing-inventory").selectOption(createdInventoryId);
await page.getByLabel("Source identifier").fill("browser-price-1");
await page.getByLabel("Observed price (cents)").fill("2500");
const [observationResponse, valuationResponse] = await Promise.all([ const [observationResponse, valuationResponse] = await Promise.all([
page.waitForResponse((response) => response.url().includes('/admin/pricing/observations')), page.waitForResponse((response) =>
page.waitForResponse((response) => response.url().includes('/valuation') && response.request().method() === 'POST'), response.url().includes("/admin/pricing/observations"),
page.getByRole('button', { name: 'Add observation and calculate' }).click(), ),
page.waitForResponse(
(response) =>
response.url().includes("/valuation") &&
response.request().method() === "POST",
),
page.getByRole("button", { name: "Add observation and calculate" }).click(),
]); ]);
expect(observationResponse.status()).toBe(201); expect(observationResponse.status()).toBe(201);
expect(valuationResponse.status()).toBe(200); expect(valuationResponse.status()).toBe(200);
const valuationRow = page.getByRole('list', { name: 'Market valuations' }).getByText(createdInventoryId); const valuationRow = page
await expect(valuationRow).toContainText('Market value $25.00'); .getByRole("list", { name: "Market valuations" })
await page.getByLabel('Manual value (cents)').fill('2700'); .getByText(createdInventoryId);
await page.getByLabel('Override reason').fill('Verified recent sale'); await expect(valuationRow).toContainText("Market value $25.00");
await page.getByRole('button', { name: 'Save manual value' }).click(); await page.getByLabel("Manual value (cents)").fill("2700");
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $27.00'); await page.getByLabel("Override reason").fill("Verified recent sale");
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('OVERRIDE confidence'); await page.getByRole("button", { name: "Save manual value" }).click();
await expect(
page.getByRole("list", { name: "Market valuations" }),
).toContainText("Market value $27.00");
await expect(
page.getByRole("list", { name: "Market valuations" }),
).toContainText("OVERRIDE confidence");
await inventoryLink.click(); await inventoryLink.click();
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/); await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible(); await expect(
await page.getByLabel('Add image').setInputFiles({ page.getByRole("heading", { name: "Manage images" }),
name: 'front.png', ).toBeVisible();
mimeType: 'image/png', await page.getByLabel("Add image").setInputFiles({
name: "front.png",
mimeType: "image/png",
buffer: tinyPng, buffer: tinyPng,
}); });
await expect(page.getByLabel('Alt text')).toHaveValue('front.png'); await expect(page.getByLabel("Alt text")).toHaveValue("front.png");
await page.goto('/admin'); await page.goto("/admin");
await expect(page.getByRole('heading', { name: 'Create listing draft' })).toBeVisible(); await expect(
await page.getByLabel('Inventory copy', { exact: true }).first().selectOption(createdInventoryId); page.getByRole("heading", { name: "Create listing draft" }),
await page.getByLabel('Asking price (cents)').fill('2499'); ).toBeVisible();
await page.getByRole('button', { name: 'Create listing' }).click(); await page
const createdListing = page.locator('section[aria-labelledby="listings-title"] article').filter({ hasText: productName }).last(); .getByLabel("Inventory copy", { exact: true })
await expect(createdListing.getByText('Add at least one image before publishing.')).toHaveCount(0); .first()
await createdListing.getByRole('button', { name: 'Publish' }).click(); .selectOption(createdInventoryId);
await expect(createdListing).toContainText('AVAILABLE'); await page.getByLabel("Asking price (cents)").fill("2499");
await page.getByRole("button", { name: "Create listing" }).click();
const createdListing = page
.locator('section[aria-labelledby="listings-title"] article')
.filter({ hasText: productName })
.last();
await expect(
createdListing.getByText("Add at least one image before publishing."),
).toHaveCount(0);
await createdListing.getByRole("button", { name: "Publish" }).click();
await expect(createdListing).toContainText("AVAILABLE");
await page.locator('#sale-inventory').selectOption(createdInventoryId); await page.locator("#sale-inventory").selectOption(createdInventoryId);
await page.getByLabel('Sale date').fill('2026-09-09'); await page.getByLabel("Sale date").fill("2026-09-09");
await page.getByLabel('Sold price (cents)').fill('2499'); await page.getByLabel("Sold price (cents)").fill("2499");
await page.getByLabel('Cost basis (cents)').fill('1200'); await page.getByLabel("Cost basis (cents)").fill("1200");
await page.getByRole('button', { name: 'Record sale' }).click(); await page.getByRole("button", { name: "Record sale" }).click();
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · SOLD`)).toBeVisible(); await expect(
await expect(page.getByRole('heading', { name: 'Sale history' })).toBeVisible(); page
const latestSale = page.locator('section[aria-labelledby="sales-title"] li').first(); .locator('section[aria-labelledby="inventory-title"]')
await expect(latestSale).toContainText('Profit $12.99'); .getByText(`${createdInventoryId} · SOLD`),
await latestSale.getByLabel('Void reason').fill('Browser test correction'); ).toBeVisible();
await latestSale.getByRole('button', { name: 'Void sale' }).click(); await expect(
await expect(latestSale).toContainText('VOIDED'); page.getByRole("heading", { name: "Sale history" }),
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · ACTIVE`)).toBeVisible(); ).toBeVisible();
const latestSale = page
.locator('section[aria-labelledby="sales-title"] li')
.first();
await expect(latestSale).toContainText("Profit $12.99");
await latestSale.getByLabel("Void reason").fill("Browser test correction");
await latestSale.getByRole("button", { name: "Void sale" }).click();
await expect(latestSale).toContainText("VOIDED");
await expect(
page
.locator('section[aria-labelledby="inventory-title"]')
.getByText(`${createdInventoryId} · ACTIVE`),
).toBeVisible();
}); });
+39 -20
View File
@@ -1,25 +1,40 @@
export async function processNextPersistentPricingRefreshJob({ jobRepository, pricingRefreshJob }) { export async function processNextPersistentPricingRefreshJob({
jobRepository,
pricingRefreshJob,
}) {
const claimed = await jobRepository.claimNext(); const claimed = await jobRepository.claimNext();
if (!claimed) return null; if (!claimed) return null;
const result = await pricingRefreshJob({ ...claimed, type: 'PRICING_REFRESH' }); const result = await pricingRefreshJob({
if (result.status === 'COMPLETED') { ...claimed,
await jobRepository.complete(claimed.id, result.observations, result.valuation); type: "PRICING_REFRESH",
});
if (result.status === "COMPLETED") {
await jobRepository.complete(
claimed.id,
result.observations,
result.valuation,
);
} else { } else {
await jobRepository.fail(claimed.id, result.error); await jobRepository.fail(claimed.id, result.error);
} }
return result; return result;
}export async function runPricingRefreshJob({ job, providerRegistry, pricingService }) { }
if (!job || job.type !== 'PRICING_REFRESH') { export async function runPricingRefreshJob({
throw new Error('Unsupported pricing refresh job.'); job,
providerRegistry,
pricingService,
}) {
if (!job || job.type !== "PRICING_REFRESH") {
throw new Error("Unsupported pricing refresh job.");
} }
const provider = providerRegistry?.get(job.providerId); const provider = providerRegistry?.get(job.providerId);
if (!provider) { if (!provider) {
return { return {
...job, ...job,
status: 'FAILED', status: "FAILED",
error: 'Pricing provider is not configured.', error: "Pricing provider is not configured.",
failedAt: new Date(), failedAt: new Date(),
}; };
} }
@@ -29,17 +44,21 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
inventoryItemId: job.inventoryItemId, inventoryItemId: job.inventoryItemId,
productId: job.productId, productId: job.productId,
conditionId: job.conditionId, conditionId: job.conditionId,
externalIdentifier: job.externalIdentifier,
searchQuery: job.searchQuery,
}); });
const savedObservations = []; const savedObservations = [];
for (const observation of observations) { for (const observation of observations) {
savedObservations.push(await pricingService.addObservation({ savedObservations.push(
sellerId: job.sellerId, await pricingService.addObservation({
...observation, sellerId: job.sellerId,
providerId: provider.id, ...observation,
inventoryItemId: job.inventoryItemId, providerId: provider.id,
productId: job.productId, inventoryItemId: job.inventoryItemId,
conditionId: job.conditionId, productId: job.productId,
})); conditionId: job.conditionId,
}),
);
} }
const valuation = await pricingService.calculate({ const valuation = await pricingService.calculate({
sellerId: job.sellerId, sellerId: job.sellerId,
@@ -48,7 +67,7 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
return { return {
...job, ...job,
status: 'COMPLETED', status: "COMPLETED",
observations: savedObservations, observations: savedObservations,
valuation, valuation,
completedAt: new Date(), completedAt: new Date(),
@@ -56,8 +75,8 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
} catch (error) { } catch (error) {
return { return {
...job, ...job,
status: 'FAILED', status: "FAILED",
error: error instanceof Error ? error.message : 'Pricing refresh failed.', error: error instanceof Error ? error.message : "Pricing refresh failed.",
failedAt: new Date(), failedAt: new Date(),
}; };
} }
+68 -31
View File
@@ -1,32 +1,59 @@
import dotenv from 'dotenv'; import dotenv from "dotenv";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
dotenv.config({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) }); dotenv.config({
path: fileURLToPath(new URL("../../../.env", import.meta.url)),
});
import { createDatabase } from '../../api/src/infrastructure/database/client.js'; import { createDatabase } from "../../api/src/infrastructure/database/client.js";
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../api/src/infrastructure/images/local-image-storage.js'; import {
import { createImageDerivativeProcessor } from '../../api/src/infrastructure/images/image-derivative-processor.js'; createLocalImageStorage,
import { createSharpImageRenderer } from '../../api/src/infrastructure/images/sharp-image-renderer.js'; resolveProjectImageStorageRoot,
import { createImageDerivativeJobRepository } from '../../api/src/modules/inventory/repositories/image-derivative-job-repository.js'; } from "../../api/src/infrastructure/images/local-image-storage.js";
import { createPricingRepository } from '../../api/src/modules/pricing/repositories/pricing-repository.js'; import { createImageDerivativeProcessor } from "../../api/src/infrastructure/images/image-derivative-processor.js";
import { createPricingRefreshJobRepository } from '../../api/src/modules/pricing/repositories/pricing-refresh-job-repository.js'; import { createSharpImageRenderer } from "../../api/src/infrastructure/images/sharp-image-renderer.js";
import { createPricingService } from '../../api/src/modules/pricing/services/pricing-service.js'; import { createImageDerivativeJobRepository } from "../../api/src/modules/inventory/repositories/image-derivative-job-repository.js";
import { createPricingProviderRegistry } from '../../api/src/modules/pricing/providers/pricing-provider.js'; import { createPricingRepository } from "../../api/src/modules/pricing/repositories/pricing-repository.js";
import { createFixturePricingProvider } from '../../api/src/modules/pricing/providers/fixture-pricing-provider.js'; import { createPricingRefreshJobRepository } from "../../api/src/modules/pricing/repositories/pricing-refresh-job-repository.js";
import { processNextPersistentImageDerivativeJob } from './image-derivative-worker.js'; import { createPricingService } from "../../api/src/modules/pricing/services/pricing-service.js";
import { processNextPersistentPricingRefreshJob, runPricingRefreshJob } from './pricing-refresh-worker.js'; import { createPricingProviderRegistry } from "../../api/src/modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from "../../api/src/modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../../api/src/modules/pricing/providers/http-pricing-provider.js";
import { processNextPersistentImageDerivativeJob } from "./image-derivative-worker.js";
import {
processNextPersistentPricingRefreshJob,
runPricingRefreshJob,
} from "./pricing-refresh-worker.js";
const pollIntervalMs = Number.parseInt(process.env.WORKER_POLL_INTERVAL_MS ?? '2000', 10); const pollIntervalMs = Number.parseInt(
process.env.WORKER_POLL_INTERVAL_MS ?? "2000",
10,
);
async function runWorker() { async function runWorker() {
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url); const imageStorageRoot = resolveProjectImageStorageRoot(
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
import.meta.url,
);
const database = createDatabase(); const database = createDatabase();
const imageStorage = createLocalImageStorage(imageStorageRoot); const imageStorage = createLocalImageStorage(imageStorageRoot);
const jobRepository = createImageDerivativeJobRepository(database.db); const jobRepository = createImageDerivativeJobRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(
const pricingProviderRegistry = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true' database.db,
? createPricingProviderRegistry([createFixturePricingProvider()]) );
: createPricingProviderRegistry(); const providers = [];
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
providers.push(createFixturePricingProvider());
if (process.env.PRICING_HTTP_PROVIDER_ENDPOINT) {
providers.push(
createHttpPricingProvider({
id: process.env.PRICING_HTTP_PROVIDER_ID ?? "http-provider",
endpoint: process.env.PRICING_HTTP_PROVIDER_ENDPOINT,
token: process.env.PRICING_HTTP_PROVIDER_TOKEN,
}),
);
}
const pricingProviderRegistry = createPricingProviderRegistry(providers);
const pricingService = createPricingService({ const pricingService = createPricingService({
pricingRepository: createPricingRepository(database.db), pricingRepository: createPricingRepository(database.db),
pricingProviderRegistry, pricingProviderRegistry,
@@ -45,29 +72,39 @@ async function runWorker() {
processor, processor,
}); });
if (result) console.log(`Image derivative job ${result.id} ${result.status.toLowerCase()}.`); if (result)
console.log(
`Image derivative job ${result.id} ${result.status.toLowerCase()}.`,
);
const pricingResult = await processNextPersistentPricingRefreshJob({ const pricingResult = await processNextPersistentPricingRefreshJob({
jobRepository: pricingRefreshJobRepository, jobRepository: pricingRefreshJobRepository,
pricingRefreshJob: (job) => runPricingRefreshJob({ pricingRefreshJob: (job) =>
job, runPricingRefreshJob({
providerRegistry: pricingProviderRegistry, job,
pricingService, providerRegistry: pricingProviderRegistry,
}), pricingService,
}),
}); });
if (pricingResult) console.log(`Pricing refresh job ${pricingResult.id} ${pricingResult.status.toLowerCase()}.`); if (pricingResult)
console.log(
`Pricing refresh job ${pricingResult.id} ${pricingResult.status.toLowerCase()}.`,
);
} }
await processOne(); await processOne();
const timer = setInterval(() => processOne().catch((error) => console.error(error)), pollIntervalMs); const timer = setInterval(
() => processOne().catch((error) => console.error(error)),
pollIntervalMs,
);
const shutdown = async () => { const shutdown = async () => {
clearInterval(timer); clearInterval(timer);
await database.close(); await database.close();
}; };
process.once('SIGINT', shutdown); process.once("SIGINT", shutdown);
process.once('SIGTERM', shutdown); process.once("SIGTERM", shutdown);
} }
runWorker().catch((error) => { runWorker().catch((error) => {
@@ -1,78 +1,111 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createPricingProvider, createPricingProviderRegistry } from '../../api/src/modules/pricing/providers/pricing-provider.js'; import {
import { processNextPersistentPricingRefreshJob, runPricingRefreshJob } from '../src/pricing-refresh-worker.js'; createPricingProvider,
createPricingProviderRegistry,
} from "../../api/src/modules/pricing/providers/pricing-provider.js";
import {
processNextPersistentPricingRefreshJob,
runPricingRefreshJob,
} from "../src/pricing-refresh-worker.js";
test('completes a pricing refresh through the provider and pricing service boundaries', async () => { test("completes a pricing refresh through the provider and pricing service boundaries", async () => {
const providerRegistry = createPricingProviderRegistry([ const providerRegistry = createPricingProviderRegistry([
createPricingProvider({ createPricingProvider({
id: 'fixture-provider', id: "fixture-provider",
async observe() { async observe() {
return [{ priceMinor: 2500, sourceIdentifier: 'fixture-1' }]; return [{ priceMinor: 2500, sourceIdentifier: "fixture-1" }];
}, },
}), }),
]); ]);
const saved = []; const saved = [];
const result = await runPricingRefreshJob({ const result = await runPricingRefreshJob({
job: { job: {
id: 'job-1', id: "job-1",
type: 'PRICING_REFRESH', type: "PRICING_REFRESH",
providerId: 'fixture-provider', providerId: "fixture-provider",
sellerId: 'seller-1', sellerId: "seller-1",
productId: 'product-1', productId: "product-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
conditionId: 'condition-1', conditionId: "condition-1",
}, },
providerRegistry, providerRegistry,
pricingService: { pricingService: {
async addObservation(input) { saved.push(input); return input; }, async addObservation(input) {
async calculate() { return { valuation: { valueMinor: 2500 } }; }, saved.push(input);
return input;
},
async calculate() {
return { valuation: { valueMinor: 2500 } };
},
}, },
}); });
assert.equal(result.status, 'COMPLETED'); assert.equal(result.status, "COMPLETED");
assert.equal(result.valuation.valuation.valueMinor, 2500); assert.equal(result.valuation.valuation.valueMinor, 2500);
assert.equal(saved[0].providerId, 'fixture-provider'); assert.equal(saved[0].providerId, "fixture-provider");
}); });
test('fails a refresh clearly when a provider is unavailable', async () => { test("fails a refresh clearly when a provider is unavailable", async () => {
const result = await runPricingRefreshJob({ const result = await runPricingRefreshJob({
job: { id: 'job-2', type: 'PRICING_REFRESH', providerId: 'missing' }, job: { id: "job-2", type: "PRICING_REFRESH", providerId: "missing" },
providerRegistry: createPricingProviderRegistry(), providerRegistry: createPricingProviderRegistry(),
pricingService: {}, pricingService: {},
}); });
assert.equal(result.status, 'FAILED'); assert.equal(result.status, "FAILED");
assert.match(result.error, /not configured/); assert.match(result.error, /not configured/);
}); });
test('persists completed and failed outcomes through the job repository', async () => { test("persists completed and failed outcomes through the job repository", async () => {
const calls = []; const calls = [];
const jobRepository = { const jobRepository = {
async claimNext() { return { id: 'job-3', type: 'PRICING_REFRESH', providerId: 'fixture' }; }, async claimNext() {
async complete(id, observations, valuation) { calls.push(['complete', id, observations, valuation]); }, return { id: "job-3", type: "PRICING_REFRESH", providerId: "fixture" };
async fail(id, error) { calls.push(['fail', id, error]); }, },
async complete(id, observations, valuation) {
calls.push(["complete", id, observations, valuation]);
},
async fail(id, error) {
calls.push(["fail", id, error]);
},
}; };
await processNextPersistentPricingRefreshJob({ await processNextPersistentPricingRefreshJob({
jobRepository, jobRepository,
async pricingRefreshJob() { return { status: 'COMPLETED', observations: [{ id: 'observation-1' }], valuation: { valueMinor: 2500 } }; }, async pricingRefreshJob() {
return {
status: "COMPLETED",
observations: [{ id: "observation-1" }],
valuation: { valueMinor: 2500 },
};
},
}); });
assert.deepEqual(calls, [['complete', 'job-3', [{ id: 'observation-1' }], { valueMinor: 2500 }]]); assert.deepEqual(calls, [
["complete", "job-3", [{ id: "observation-1" }], { valueMinor: 2500 }],
]);
}); });
test('persists failed pricing refresh outcomes through the repository', async () => { test("persists failed pricing refresh outcomes through the repository", async () => {
const calls = []; const calls = [];
await processNextPersistentPricingRefreshJob({ await processNextPersistentPricingRefreshJob({
jobRepository: { jobRepository: {
async claimNext() { return { id: 'job-4' }; }, async claimNext() {
async complete() { throw new Error('not expected'); }, return { id: "job-4" };
async fail(id, error) { calls.push([id, error]); }, },
async complete() {
throw new Error("not expected");
},
async fail(id, error) {
calls.push([id, error]);
},
},
async pricingRefreshJob() {
return { status: "FAILED", error: "Provider unavailable." };
}, },
async pricingRefreshJob() { return { status: 'FAILED', error: 'Provider unavailable.' }; },
}); });
assert.deepEqual(calls, [['job-4', 'Provider unavailable.']]); assert.deepEqual(calls, [["job-4", "Provider unavailable."]]);
}); });
@@ -0,0 +1,2 @@
ALTER TABLE "pricing_observations" ADD COLUMN "external_identifier" text;
ALTER TABLE "pricing_observations" ADD COLUMN "search_query" text;
+7
View File
@@ -141,6 +141,13 @@
"when": 1790236800000, "when": 1790236800000,
"tag": "0019_pricing_job_retries", "tag": "0019_pricing_job_retries",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1790323200000,
"tag": "0020_pricing_job_mapping_context",
"breakpoints": true
} }
] ]
} }
+12 -19
View File
@@ -1,6 +1,6 @@
# Implementation Status # Implementation Status
Last updated: 2026-09-08 Last updated: 2026-09-09
## Resume Here ## Resume Here
@@ -118,44 +118,37 @@ docker context use orbstack
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
Latest result: API, web, build, Storybook build, and Storybook interaction/accessibility checks pass locally. The isolated integration suite passes 20 tests, including acquisition, pricing, multi-item sales, audited sale voiding, and seller ownership persistence. The authenticated E2E workflow covers acquisition, image upload, pricing, listing publication, sale recording, dashboard loading, and audited sale voiding. Verified locally: `npm test` passes 116 tests with no failures; worker tests, web tests, web build, Storybook build, and Storybook interaction/accessibility checks pass. The integration suite and authenticated E2E workflow are defined in CI; they require the isolated test database/Redis services and should not be described as locally verified unless those services are available.
## Next Milestone ## Next Milestone
Complete operational hardening around the implemented vertical slices. Complete production configuration and operational maturity around the implemented vertical slices.
1. Add integration coverage for the latest acquisition, sales, pricing, and dashboard migrations. 1. Configure and validate an approved external pricing provider and provider-specific mapping data.
2. Document and verify CI execution of integration, E2E, and Storybook gates. 2. Schedule pricing-job retention cleanup and establish restore drills for PostgreSQL and image storage.
3. Add provider adapters and asynchronous pricing refresh jobs behind the existing pricing boundary. 3. Add richer exports/settings, broader accessibility/manual coverage, and any remaining integration edge cases.
Acceptance criteria: public DTOs remain private-data-safe, migrations apply to clean test databases, and CI runs the same meaningful gates used for local vertical-slice validation. Acceptance criteria: production provider credentials are secret-managed, migrations and backups are exercised in the target environment, and CI runs integration, authenticated E2E, web, build, Storybook build, and Storybook interaction/accessibility gates.
## Overall Roadmap ## Overall Roadmap
Work through one bounded vertical slice at a time. The linked documentation remains authoritative when this roadmap is less detailed. Work through one bounded vertical slice at a time. The linked documentation remains authoritative when this roadmap is less detailed.
1. Authentication hardening. Completed vertical slices include authentication, listings and image prerequisites, acquisitions, sales and audited corrections, dashboard metrics/alerts, pricing observations and quality handling, provider mappings, durable pricing refresh jobs, public search/detail/image states, admin workflows, CI integration/E2E gates, production Docker packaging, rollback/health checks, PostgreSQL backup verification, and deployment smoke checks.
2. Listings and image prerequisites: keep Listing separate from Inventory Item; enforce publish requirements from [REQUIREMENTS.md](REQUIREMENTS.md).
3. Acquisition, pricing provider mappings, observations, valuations, and private/public DTO boundaries from [DATA-MODEL.md](DATA-MODEL.md) and [PRICING.md](PRICING.md). Remaining roadmap areas are richer exports/settings, approved external provider credentials/adapters, scheduled operational cleanup, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation.
4. Sales workflow, sold-item locking, corrections, cost basis, and audit history.
5. SearchService, public catalog/search endpoints, filtering, pagination, and public product URLs.
6. Image storage abstraction, upload validation, processing worker, responsive variants, and alt-text editing.
7. Vue admin workflows and public catalog, following [DESIGN.md](DESIGN.md), [UX.md](UX.md), and [ACCESSIBILITY.md](ACCESSIBILITY.md).
8. Dashboard, bulk operations, exports, settings, workers, and provider adapters.
9. CI, Docker deployment completion, backups, end-to-end/accessibility coverage, and operational documentation.
## 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 remains. - 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.
- Concrete provider adapters, asynchronous pricing refresh transport, richer exports/settings, full integration CI services, and deployment/backup automation remain incomplete to varying degrees. - The provider abstraction, fixture provider, HTTP provider adapter, Product mapping workflow, durable refresh queue, worker execution, polling contract, retry policy, and completed-job purge operation are implemented. Production readiness still requires approved external provider credentials and provider-specific mapping data.
- The pricing refresh worker now claims durable PostgreSQL jobs with row locking, persists completed/failed outcomes, and routes provider observations through the pricing service; configured external providers remain deployment work.
- Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline. - Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline.
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling. - Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly. - Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly.
- Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries. - Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries.
- Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff and an explicit completed-job purge operation for retention scheduling. - Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff and an explicit completed-job purge operation for retention scheduling.
- `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 audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.
## Local Commands ## Local Commands
+18 -9
View File
@@ -2,15 +2,24 @@ export function toAdminDashboardDto(dashboard) {
return { return {
counts: dashboard.counts, counts: dashboard.counts,
financials: dashboard.financials, financials: dashboard.financials,
recentSales: dashboard.recentSales.map(({ sale, saleItem, inventoryItem, productName, netProceedsMinor, profitMinor }) => ({ recentSales: dashboard.recentSales.map(
id: sale.id, ({
productName: productName ?? inventoryItem.id, sale,
saleDate: sale.saleDate, saleItem,
soldPriceMinor: sale.soldPriceMinor, inventoryItem,
netProceedsMinor, productName,
profitMinor, netProceedsMinor,
inventoryItemId: saleItem.inventoryItemId, profitMinor,
})), }) => ({
id: sale.id,
productName: productName ?? inventoryItem.id,
saleDate: sale.saleDate,
soldPriceMinor: sale.soldPriceMinor,
netProceedsMinor,
profitMinor,
inventoryItemId: saleItem.inventoryItemId,
}),
),
recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({ recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({
id: acquisition.id, id: acquisition.id,
inventoryItemId: acquisition.inventoryItemId, inventoryItemId: acquisition.inventoryItemId,
+2 -2
View File
@@ -14,8 +14,8 @@
"./public-listing": "./public-listing.js", "./public-listing": "./public-listing.js",
"./public-inventory": "./public-inventory.js", "./public-inventory": "./public-inventory.js",
"./public-product": "./public-product.js", "./public-product": "./public-product.js",
"./public-seller": "./public-seller.js" "./public-seller": "./public-seller.js",
,"./pricing": "./pricing.js", "./pricing": "./pricing.js",
"./provider-mapping": "./provider-mapping.js" "./provider-mapping": "./provider-mapping.js"
} }
} }
+6 -2
View File
@@ -1,4 +1,6 @@
function toIsoString(value) { return value instanceof Date ? value.toISOString() : value; } function toIsoString(value) {
return value instanceof Date ? value.toISOString() : value;
}
export function toAdminPricingObservationDto(observation) { export function toAdminPricingObservationDto(observation) {
return { return {
@@ -28,7 +30,9 @@ export function toAdminMarketValuationDto({ valuation, observations }) {
confidence: valuation.confidence, confidence: valuation.confidence,
sourceCount: valuation.sourceCount, sourceCount: valuation.sourceCount,
calculatedAt: toIsoString(valuation.calculatedAt), calculatedAt: toIsoString(valuation.calculatedAt),
stale: Date.now() - new Date(valuation.calculatedAt).getTime() > 7 * 24 * 60 * 60 * 1000, stale:
Date.now() - new Date(valuation.calculatedAt).getTime() >
7 * 24 * 60 * 60 * 1000,
observations: observations.map(toAdminPricingObservationDto), observations: observations.map(toAdminPricingObservationDto),
}; };
} }
+8 -2
View File
@@ -9,7 +9,13 @@ export function toAdminProviderMappingDto(mapping) {
sourceUrl: mapping.sourceUrl ?? null, sourceUrl: mapping.sourceUrl ?? null,
status: mapping.status, status: mapping.status,
metadata: mapping.metadata ?? {}, metadata: mapping.metadata ?? {},
createdAt: mapping.createdAt instanceof Date ? mapping.createdAt.toISOString() : mapping.createdAt, createdAt:
updatedAt: mapping.updatedAt instanceof Date ? mapping.updatedAt.toISOString() : mapping.updatedAt, mapping.createdAt instanceof Date
? mapping.createdAt.toISOString()
: mapping.createdAt,
updatedAt:
mapping.updatedAt instanceof Date
? mapping.updatedAt.toISOString()
: mapping.updatedAt,
}; };
} }
+14 -2
View File
@@ -2,7 +2,16 @@ function toIsoString(value) {
return value instanceof Date ? value.toISOString() : value; return value instanceof Date ? value.toISOString() : value;
} }
export function toAdminSaleDto({ sale, saleItem, inventoryItem, saleItems = [saleItem], inventoryItems = [inventoryItem], netProceedsMinor, profitMinor, productName }) { export function toAdminSaleDto({
sale,
saleItem,
inventoryItem,
saleItems = [saleItem],
inventoryItems = [inventoryItem],
netProceedsMinor,
profitMinor,
productName,
}) {
return { return {
id: sale.id, id: sale.id,
sellerId: sale.sellerId, sellerId: sale.sellerId,
@@ -40,7 +49,10 @@ export function toAdminSaleDto({ sale, saleItem, inventoryItem, saleItems = [sal
id: inventoryItem.id, id: inventoryItem.id,
status: inventoryItem.status, status: inventoryItem.status,
}, },
inventoryItems: inventoryItems.map((item) => ({ id: item.id, status: item.status })), inventoryItems: inventoryItems.map((item) => ({
id: item.id,
status: item.status,
})),
createdAt: toIsoString(sale.createdAt), createdAt: toIsoString(sale.createdAt),
updatedAt: toIsoString(sale.updatedAt), updatedAt: toIsoString(sale.updatedAt),
}; };
+21 -15
View File
@@ -1,16 +1,22 @@
import { z } from 'zod'; import { z } from "zod";
export const createAcquisitionSchema = z.object({ export const createAcquisitionSchema = z
inventoryItemId: z.uuid(), .object({
method: z.string().trim().min(1).max(100), inventoryItemId: z.uuid(),
costMinor: z.int().nonnegative(), method: z.string().trim().min(1).max(100),
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), costMinor: z.int().nonnegative(),
date: z.string().trim().max(10).optional(), currency: z
source: z.string().trim().max(255).optional(), .string()
location: z.string().trim().max(255).optional(), .trim()
shippingMinor: z.int().nonnegative().optional(), .regex(/^[A-Za-z]{3}$/)
taxMinor: z.int().nonnegative().optional(), .optional(),
otherFeesMinor: z.int().nonnegative().optional(), date: z.string().trim().max(10).optional(),
estimatedTradeValueMinor: z.int().nonnegative().optional(), source: z.string().trim().max(255).optional(),
notes: z.string().trim().max(10000).optional(), location: z.string().trim().max(255).optional(),
}).strict(); shippingMinor: z.int().nonnegative().optional(),
taxMinor: z.int().nonnegative().optional(),
otherFeesMinor: z.int().nonnegative().optional(),
estimatedTradeValueMinor: z.int().nonnegative().optional(),
notes: z.string().trim().max(10000).optional(),
})
.strict();
+2 -2
View File
@@ -9,8 +9,8 @@
"./image": "./image.js", "./image": "./image.js",
"./listing": "./listing.js", "./listing": "./listing.js",
"./sale": "./sale.js", "./sale": "./sale.js",
"./product": "./product.js" "./product": "./product.js",
,"./pricing": "./pricing.js", "./pricing": "./pricing.js",
"./provider-mapping": "./provider-mapping.js" "./provider-mapping": "./provider-mapping.js"
}, },
"dependencies": { "dependencies": {
+29 -17
View File
@@ -1,19 +1,31 @@
import { z } from 'zod'; import { z } from "zod";
export const createPricingObservationSchema = z.object({ export const createPricingObservationSchema = z
productId: z.uuid(), .object({
inventoryItemId: z.uuid(), productId: z.uuid(),
providerId: z.string().trim().min(1).max(100), inventoryItemId: z.uuid(),
priceMinor: z.int().positive(), providerId: z.string().trim().min(1).max(100),
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), priceMinor: z.int().positive(),
sourceIdentifier: z.string().trim().min(1).max(255), currency: z
conditionId: z.uuid(), .string()
sourceUrl: z.string().trim().url().optional(), .trim()
qualityScore: z.int().min(0).max(100).optional(), .regex(/^[A-Za-z]{3}$/)
}).strict(); .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();
export const createMarketValueOverrideSchema = z.object({ export const createMarketValueOverrideSchema = z
valueMinor: z.int().nonnegative(), .object({
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), valueMinor: z.int().nonnegative(),
reason: z.string().trim().min(3).max(1000), currency: z
}).strict(); .string()
.trim()
.regex(/^[A-Za-z]{3}$/)
.optional(),
reason: z.string().trim().min(3).max(1000),
})
.strict();
+11 -9
View File
@@ -1,10 +1,12 @@
import { z } from 'zod'; import { z } from "zod";
export const createProviderMappingSchema = z.object({ export const createProviderMappingSchema = z
providerId: z.string().trim().min(1).max(100), .object({
mappingType: z.enum(['PRODUCT', 'SEARCH']), providerId: z.string().trim().min(1).max(100),
externalIdentifier: z.string().trim().max(255).optional(), mappingType: z.enum(["PRODUCT", "SEARCH"]),
searchQuery: z.string().trim().max(1000).optional(), externalIdentifier: z.string().trim().max(255).optional(),
sourceUrl: z.string().trim().url().optional(), searchQuery: z.string().trim().max(1000).optional(),
metadata: z.record(z.string(), z.unknown()).optional(), sourceUrl: z.string().trim().url().optional(),
}).strict(); metadata: z.record(z.string(), z.unknown()).optional(),
})
.strict();
+40 -34
View File
@@ -1,4 +1,4 @@
import { z } from 'zod'; import { z } from "zod";
const isoDate = /^\d{4}-\d{2}-\d{2}$/; const isoDate = /^\d{4}-\d{2}-\d{2}$/;
const currencyCode = /^[A-Za-z]{3}$/; const currencyCode = /^[A-Za-z]{3}$/;
@@ -8,38 +8,44 @@ const saleItemSchema = z.object({
costBasisMinor: z.int().nonnegative(), costBasisMinor: z.int().nonnegative(),
}); });
export const createSaleSchema = z.object({ export const createSaleSchema = z
inventoryItemId: z.uuid(), .object({
costBasisMinor: z.int().nonnegative(), inventoryItemId: z.uuid(),
saleDate: z.string().trim().regex(isoDate), costBasisMinor: z.int().nonnegative(),
saleLocation: z.string().trim().max(255).optional(), saleDate: z.string().trim().regex(isoDate),
soldPriceMinor: z.int().positive(), saleLocation: z.string().trim().max(255).optional(),
currency: z.string().trim().regex(currencyCode).optional(), soldPriceMinor: z.int().positive(),
platformFeesMinor: z.int().nonnegative().optional(), currency: z.string().trim().regex(currencyCode).optional(),
paymentProcessingFeesMinor: z.int().nonnegative().optional(), platformFeesMinor: z.int().nonnegative().optional(),
shippingCostMinor: z.int().nonnegative().optional(), paymentProcessingFeesMinor: z.int().nonnegative().optional(),
taxesMinor: z.int().nonnegative().optional(), shippingCostMinor: z.int().nonnegative().optional(),
otherFeesMinor: z.int().nonnegative().optional(), taxesMinor: z.int().nonnegative().optional(),
buyerName: z.string().trim().max(255).optional(), otherFeesMinor: z.int().nonnegative().optional(),
buyerContact: z.string().trim().max(255).optional(), buyerName: z.string().trim().max(255).optional(),
notes: z.string().trim().max(10000).optional(), buyerContact: z.string().trim().max(255).optional(),
}).strict(); notes: z.string().trim().max(10000).optional(),
})
.strict();
export const createMultiItemSaleSchema = z.object({ export const createMultiItemSaleSchema = z
items: z.array(saleItemSchema).min(2), .object({
saleDate: z.string().trim().regex(isoDate), items: z.array(saleItemSchema).min(2),
saleLocation: z.string().trim().max(255).optional(), saleDate: z.string().trim().regex(isoDate),
currency: z.string().trim().regex(currencyCode).optional(), saleLocation: z.string().trim().max(255).optional(),
platformFeesMinor: z.int().nonnegative().optional(), currency: z.string().trim().regex(currencyCode).optional(),
paymentProcessingFeesMinor: z.int().nonnegative().optional(), platformFeesMinor: z.int().nonnegative().optional(),
shippingCostMinor: z.int().nonnegative().optional(), paymentProcessingFeesMinor: z.int().nonnegative().optional(),
taxesMinor: z.int().nonnegative().optional(), shippingCostMinor: z.int().nonnegative().optional(),
otherFeesMinor: z.int().nonnegative().optional(), taxesMinor: z.int().nonnegative().optional(),
buyerName: z.string().trim().max(255).optional(), otherFeesMinor: z.int().nonnegative().optional(),
buyerContact: z.string().trim().max(255).optional(), buyerName: z.string().trim().max(255).optional(),
notes: z.string().trim().max(10000).optional(), buyerContact: z.string().trim().max(255).optional(),
}).strict(); notes: z.string().trim().max(10000).optional(),
})
.strict();
export const voidSaleSchema = z.object({ export const voidSaleSchema = z
reason: z.string().trim().min(3).max(1000), .object({
}).strict(); reason: z.string().trim().min(3).max(1000),
})
.strict();