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:
@@ -1,32 +1,33 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import multipart from '@fastify/multipart';
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import multipart from "@fastify/multipart";
|
||||
|
||||
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
|
||||
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
|
||||
import { registerAdminLookupRoutes } from '../modules/inventory/routes/admin-lookup-routes.js';
|
||||
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
|
||||
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js';
|
||||
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js';
|
||||
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
|
||||
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js';
|
||||
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
|
||||
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
||||
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
||||
import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js';
|
||||
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
||||
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js';
|
||||
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js';
|
||||
import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js';
|
||||
import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js';
|
||||
import { registerAuthRoutes } from "../modules/auth/routes/auth-routes.js";
|
||||
import { registerAdminInventoryRoutes } from "../modules/inventory/routes/admin-inventory-routes.js";
|
||||
import { registerAdminLookupRoutes } from "../modules/inventory/routes/admin-lookup-routes.js";
|
||||
import { registerAdminImageRoutes } from "../modules/inventory/routes/admin-image-routes.js";
|
||||
import { registerPublicInventoryRoutes } from "../modules/inventory/routes/public-inventory-routes.js";
|
||||
import { registerPublicImageRoutes } from "../modules/inventory/routes/public-image-routes.js";
|
||||
import { registerAdminListingRoutes } from "../modules/listings/routes/admin-listing-routes.js";
|
||||
import { registerPublicListingRoutes } from "../modules/listings/routes/public-listing-routes.js";
|
||||
import { registerAdminProductRoutes } from "../modules/products/routes/admin-product-routes.js";
|
||||
import { registerPublicProductRoutes } from "../modules/products/routes/public-product-routes.js";
|
||||
import { registerPublicSearchRoutes } from "../modules/search/routes/public-search-routes.js";
|
||||
import { registerPublicSellerRoutes } from "../modules/sellers/routes/public-seller-routes.js";
|
||||
import { registerPublicLookupRoutes } from "../modules/inventory/routes/public-lookup-routes.js";
|
||||
import { registerAdminSaleRoutes } from "../modules/sales/routes/admin-sale-routes.js";
|
||||
import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/admin-acquisition-routes.js";
|
||||
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
||||
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
||||
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
||||
|
||||
export function buildApp(dependencies) {
|
||||
const app = Fastify();
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
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) {
|
||||
request.log.error(error);
|
||||
@@ -34,87 +35,87 @@ export function buildApp(dependencies) {
|
||||
|
||||
return reply.code(statusCode).send({
|
||||
error: {
|
||||
code: error.code ?? 'INTERNAL_SERVER_ERROR',
|
||||
code: error.code ?? "INTERNAL_SERVER_ERROR",
|
||||
message,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ data: { status: 'ok' } }));
|
||||
app.get("/health", async () => ({ data: { status: "ok" } }));
|
||||
app.register(cookie);
|
||||
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
||||
app.register(registerAuthRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/auth',
|
||||
prefix: "/api/v1/auth",
|
||||
});
|
||||
app.register(registerAdminProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminLookupRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminSaleRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminAcquisitionRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminDashboardRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminPricingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminProviderMappingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerPublicSearchRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicSellerRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicLookupRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
+120
-72
@@ -1,85 +1,106 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from "dotenv";
|
||||
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 { createDatabase } from '../infrastructure/database/client.js';
|
||||
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js';
|
||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||
import { createMfaService } from '../modules/auth/services/mfa-service.js';
|
||||
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js';
|
||||
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js';
|
||||
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js';
|
||||
import { createUserRepository } from '../modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js';
|
||||
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js';
|
||||
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js';
|
||||
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createCatalogLookupRepository } from '../modules/inventory/repositories/catalog-lookup-repository.js';
|
||||
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
|
||||
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js';
|
||||
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js';
|
||||
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createImageDerivativeJobRepository } from '../modules/inventory/repositories/image-derivative-job-repository.js';
|
||||
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
|
||||
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js';
|
||||
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js';
|
||||
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js';
|
||||
import { createListingService } from '../modules/listings/services/listing-service.js';
|
||||
import { createPublicListingService } from '../modules/listings/services/public-listing-service.js';
|
||||
import { createProductRepository } from '../modules/products/repositories/product-repository.js';
|
||||
import { createProductService } from '../modules/products/services/product-service.js';
|
||||
import { createPublicProductService } from '../modules/products/services/public-product-service.js';
|
||||
import { createSearchService } from '../modules/search/services/search-service.js';
|
||||
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
||||
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
||||
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js';
|
||||
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
||||
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js';
|
||||
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js';
|
||||
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js';
|
||||
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js';
|
||||
import { createPricingService } from '../modules/pricing/services/pricing-service.js';
|
||||
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js';
|
||||
import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js';
|
||||
import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js';
|
||||
import { createPricingProviderRegistry } from '../modules/pricing/providers/pricing-provider.js';
|
||||
import { createFixturePricingProvider } from '../modules/pricing/providers/fixture-pricing-provider.js';
|
||||
import { buildApp } from "./build-app.js";
|
||||
import { createDatabase } from "../infrastructure/database/client.js";
|
||||
import { resolveProjectImageStorageRoot } from "../infrastructure/images/local-image-storage.js";
|
||||
import { createPasswordResetNotifier } from "../infrastructure/email/password-reset-notifier.js";
|
||||
import { createRedisConnection } from "../infrastructure/redis/client.js";
|
||||
import { createAuthenticationService } from "../modules/auth/services/authentication-service.js";
|
||||
import { createMfaService } from "../modules/auth/services/mfa-service.js";
|
||||
import { createSecretProtector } from "../modules/auth/domain/secret-protection.js";
|
||||
import { createMfaMethodRepository } from "../modules/auth/repositories/mfa-method-repository.js";
|
||||
import { createSecurityAuditRepository } from "../modules/auth/repositories/security-audit-repository.js";
|
||||
import { createSessionRepository } from "../modules/auth/repositories/session-repository.js";
|
||||
import { createUserRepository } from "../modules/auth/repositories/user-repository.js";
|
||||
import { createPasswordResetRepository } from "../modules/auth/repositories/password-reset-repository.js";
|
||||
import { createRedisLoginRateLimiter } from "../modules/auth/services/login-rate-limiter.js";
|
||||
import { createPasswordRecoveryService } from "../modules/auth/services/password-recovery-service.js";
|
||||
import { createInventoryItemRepository } from "../modules/inventory/repositories/inventory-item-repository.js";
|
||||
import { createCatalogLookupRepository } from "../modules/inventory/repositories/catalog-lookup-repository.js";
|
||||
import { createInventoryItemService } from "../modules/inventory/services/inventory-item-service.js";
|
||||
import { createPublicInventoryService } from "../modules/inventory/services/public-inventory-service.js";
|
||||
import { createPublicImageService } from "../modules/inventory/services/public-image-service.js";
|
||||
import { createInventoryImageRepository } from "../modules/inventory/repositories/inventory-image-repository.js";
|
||||
import { createImageDerivativeJobRepository } from "../modules/inventory/repositories/image-derivative-job-repository.js";
|
||||
import { createInventoryImageService } from "../modules/inventory/services/inventory-image-service.js";
|
||||
import { createLocalImageStorage } from "../infrastructure/images/local-image-storage.js";
|
||||
import { createConditionRepository } from "../modules/inventory/repositories/condition-repository.js";
|
||||
import { createListingRepository } from "../modules/listings/repositories/listing-repository.js";
|
||||
import { createListingService } from "../modules/listings/services/listing-service.js";
|
||||
import { createPublicListingService } from "../modules/listings/services/public-listing-service.js";
|
||||
import { createProductRepository } from "../modules/products/repositories/product-repository.js";
|
||||
import { createProductService } from "../modules/products/services/product-service.js";
|
||||
import { createPublicProductService } from "../modules/products/services/public-product-service.js";
|
||||
import { createSearchService } from "../modules/search/services/search-service.js";
|
||||
import { createSellerRepository } from "../modules/sellers/repositories/seller-repository.js";
|
||||
import { createPublicSellerService } from "../modules/sellers/services/public-seller-service.js";
|
||||
import { createSaleRepository } from "../modules/sales/repositories/sale-repository.js";
|
||||
import { createSaleService } from "../modules/sales/services/sale-service.js";
|
||||
import { createAcquisitionRepository } from "../modules/acquisitions/repositories/acquisition-repository.js";
|
||||
import { createAcquisitionService } from "../modules/acquisitions/services/acquisition-service.js";
|
||||
import { createDashboardService } from "../modules/dashboard/services/dashboard-service.js";
|
||||
import { createPricingRepository } from "../modules/pricing/repositories/pricing-repository.js";
|
||||
import { createPricingService } from "../modules/pricing/services/pricing-service.js";
|
||||
import { createPricingRefreshJobRepository } from "../modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||
import { createProviderMappingRepository } from "../modules/pricing/repositories/provider-mapping-repository.js";
|
||||
import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
|
||||
import { createPricingProviderRegistry } from "../modules/pricing/providers/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) {
|
||||
const port = Number.parseInt(value ?? '3000', 10);
|
||||
const port = Number.parseInt(value ?? "3000", 10);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 redis = await createRedisConnection();
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const catalogLookupRepository = createCatalogLookupRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
const imageDerivativeJobRepository = createImageDerivativeJobRepository(database.db);
|
||||
const imageDerivativeJobRepository = createImageDerivativeJobRepository(
|
||||
database.db,
|
||||
);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const sellerRepository = createSellerRepository(database.db);
|
||||
const conditionRepository = createConditionRepository(database.db);
|
||||
const saleRepository = createSaleRepository(database.db);
|
||||
const acquisitionRepository = createAcquisitionRepository(database.db);
|
||||
const pricingRepository = createPricingRepository(database.db);
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
||||
const providerMappingRepository = createProviderMappingRepository(database.db);
|
||||
const pricingProviders = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true'
|
||||
? createPricingProviderRegistry([createFixturePricingProvider()])
|
||||
: createPricingProviderRegistry();
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||
database.db,
|
||||
);
|
||||
const providerMappingRepository = createProviderMappingRepository(
|
||||
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 sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
@@ -105,11 +126,15 @@ async function startServer() {
|
||||
const app = buildApp({
|
||||
authenticationService,
|
||||
authenticateAdmin: (request) =>
|
||||
authenticationService.authenticate(request.cookies.pokemon_inventory_session),
|
||||
authenticationService.authenticate(
|
||||
request.cookies.pokemon_inventory_session,
|
||||
),
|
||||
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
||||
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }),
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
loginRateLimiter: createRedisLoginRateLimiter({
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
mfaService,
|
||||
passwordRecoveryService: createPasswordRecoveryService({
|
||||
passwordResetNotifier,
|
||||
@@ -118,7 +143,7 @@ async function startServer() {
|
||||
userRepository,
|
||||
}),
|
||||
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
||||
keyPrefix: 'pokemon-inventory:auth:password-reset:',
|
||||
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
@@ -128,42 +153,65 @@ async function startServer() {
|
||||
}),
|
||||
catalogLookupRepository,
|
||||
publicImageService,
|
||||
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
||||
publicInventoryService: createPublicInventoryService({
|
||||
inventoryItemRepository,
|
||||
publicImageService,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
saleService: createSaleService({ saleRepository }),
|
||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||
dashboardService: createDashboardService({
|
||||
productService: createProductService({ productRepository }),
|
||||
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}),
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
saleService: createSaleService({ saleRepository }),
|
||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
}),
|
||||
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }),
|
||||
providerMappingService: createProviderMappingService({ providerMappingRepository }),
|
||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||
pricingService: createPricingService({
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
providerMappingRepository,
|
||||
pricingProviderRegistry: pricingProviders,
|
||||
}),
|
||||
providerMappingService: createProviderMappingService({
|
||||
providerMappingRepository,
|
||||
}),
|
||||
publicListingService: createPublicListingService({
|
||||
listingRepository,
|
||||
publicImageService,
|
||||
}),
|
||||
productService: createProductService({ productRepository }),
|
||||
publicProductService: createPublicProductService({ productRepository }),
|
||||
searchService: createSearchService({ listingRepository }),
|
||||
publicSellerService: createPublicSellerService({ sellerRepository }),
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
app.addHook("onClose", async () => {
|
||||
await database.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) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,100 +10,100 @@ import {
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const userRole = pgEnum('user_role', ['ADMIN']);
|
||||
export const inventoryStatus = pgEnum('inventory_status', [
|
||||
'ACTIVE',
|
||||
'SOLD',
|
||||
'ARCHIVED',
|
||||
export const userRole = pgEnum("user_role", ["ADMIN"]);
|
||||
export const inventoryStatus = pgEnum("inventory_status", [
|
||||
"ACTIVE",
|
||||
"SOLD",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
export const listingStatus = pgEnum('listing_status', [
|
||||
'DRAFT',
|
||||
'AVAILABLE',
|
||||
'PENDING',
|
||||
'CLOSED',
|
||||
'ARCHIVED',
|
||||
export const listingStatus = pgEnum("listing_status", [
|
||||
"DRAFT",
|
||||
"AVAILABLE",
|
||||
"PENDING",
|
||||
"CLOSED",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
};
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
"users",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: userRole('role').default('ADMIN').notNull(),
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
role: userRole("role").default("ADMIN").notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('users_email_unique').on(table.email)],
|
||||
(table) => [uniqueIndex("users_email_unique").on(table.email)],
|
||||
);
|
||||
|
||||
export const sellers = pgTable(
|
||||
'sellers',
|
||||
"sellers",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
displayName: text('display_name').notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)],
|
||||
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
"sessions",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
sessionTokenHash: text('session_token_hash').notNull(),
|
||||
csrfTokenHash: text('csrf_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true })
|
||||
sessionTokenHash: text("session_token_hash").notNull(),
|
||||
csrfTokenHash: text("csrf_token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp("last_activity_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash),
|
||||
index('sessions_user_id_index').on(table.userId),
|
||||
index('sessions_expires_at_index').on(table.expiresAt),
|
||||
uniqueIndex("sessions_token_hash_unique").on(table.sessionTokenHash),
|
||||
index("sessions_user_id_index").on(table.userId),
|
||||
index("sessions_expires_at_index").on(table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const securityAuditEvents = pgTable(
|
||||
'security_audit_events',
|
||||
"security_audit_events",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id),
|
||||
event: text('event').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
event: text("event").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
metadata: jsonb("metadata").default({}).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('security_audit_events_user_id_index').on(table.userId),
|
||||
index('security_audit_events_event_created_at_index').on(
|
||||
index("security_audit_events_user_id_index").on(table.userId),
|
||||
index("security_audit_events_event_created_at_index").on(
|
||||
table.event,
|
||||
table.createdAt,
|
||||
),
|
||||
@@ -111,89 +111,89 @@ export const securityAuditEvents = pgTable(
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
'password_reset_tokens',
|
||||
"password_reset_tokens",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash),
|
||||
index('password_reset_tokens_user_id_index').on(table.userId),
|
||||
uniqueIndex("password_reset_tokens_token_hash_unique").on(table.tokenHash),
|
||||
index("password_reset_tokens_user_id_index").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const mfaMethods = pgTable(
|
||||
'mfa_methods',
|
||||
"mfa_methods",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text('type').notNull(),
|
||||
secretCiphertext: text('secret_ciphertext').notNull(),
|
||||
name: text('name').notNull(),
|
||||
isEnabled: boolean('is_enabled').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
type: text("type").notNull(),
|
||||
secretCiphertext: text("secret_ciphertext").notNull(),
|
||||
name: text("name").notNull(),
|
||||
isEnabled: boolean("is_enabled").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
lastUsedAt: timestamp("last_used_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(
|
||||
'categories',
|
||||
"categories",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('categories_name_unique').on(table.name),
|
||||
uniqueIndex('categories_slug_unique').on(table.slug),
|
||||
uniqueIndex("categories_name_unique").on(table.name),
|
||||
uniqueIndex("categories_slug_unique").on(table.slug),
|
||||
],
|
||||
);
|
||||
|
||||
export const conditionProfiles = pgTable('condition_profiles', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
categoryId: uuid('category_id')
|
||||
export const conditionProfiles = pgTable("condition_profiles", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
categoryId: uuid("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
name: text("name").notNull(),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const conditions = pgTable(
|
||||
'conditions',
|
||||
"conditions",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid('condition_profile_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid("condition_profile_id")
|
||||
.notNull()
|
||||
.references(() => conditionProfiles.id),
|
||||
name: text('name').notNull(),
|
||||
code: text('code').notNull(),
|
||||
qualityRank: integer('quality_rank').notNull(),
|
||||
description: text('description'),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
name: text("name").notNull(),
|
||||
code: text("code").notNull(),
|
||||
qualityRank: integer("quality_rank").notNull(),
|
||||
description: text("description"),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('conditions_profile_code_unique').on(
|
||||
uniqueIndex("conditions_profile_code_unique").on(
|
||||
table.conditionProfileId,
|
||||
table.code,
|
||||
),
|
||||
@@ -201,229 +201,290 @@ export const conditions = pgTable(
|
||||
);
|
||||
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
"products",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
categoryId: uuid('category_id')
|
||||
categoryId: uuid("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
description: text('description'),
|
||||
brand: text('brand'),
|
||||
setName: text('set_name'),
|
||||
cardNumber: text('card_number'),
|
||||
eventName: text('event_name'),
|
||||
releaseYear: integer('release_year'),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
description: text("description"),
|
||||
brand: text("brand"),
|
||||
setName: text("set_name"),
|
||||
cardNumber: text("card_number"),
|
||||
eventName: text("event_name"),
|
||||
releaseYear: integer("release_year"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug),
|
||||
uniqueIndex("products_seller_slug_unique").on(table.sellerId, table.slug),
|
||||
check(
|
||||
'products_release_year_valid',
|
||||
"products_release_year_valid",
|
||||
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const inventoryItems = pgTable('inventory_items', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id')
|
||||
export const inventoryItems = pgTable("inventory_items", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
conditionId: uuid('condition_id')
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
status: inventoryStatus('status').default('ACTIVE').notNull(),
|
||||
notes: text('notes'),
|
||||
status: inventoryStatus("status").default("ACTIVE").notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const acquisitions = pgTable('acquisitions', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
export const acquisitions = pgTable("acquisitions", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
method: text('method').notNull(),
|
||||
costMinor: integer('cost_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
date: text('date'),
|
||||
source: text('source'),
|
||||
location: text('location'),
|
||||
shippingMinor: integer('shipping_minor').default(0).notNull(),
|
||||
taxMinor: integer('tax_minor').default(0).notNull(),
|
||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||
estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(),
|
||||
notes: text('notes'),
|
||||
method: text("method").notNull(),
|
||||
costMinor: integer("cost_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
date: text("date"),
|
||||
source: text("source"),
|
||||
location: text("location"),
|
||||
shippingMinor: integer("shipping_minor").default(0).notNull(),
|
||||
taxMinor: integer("tax_minor").default(0).notNull(),
|
||||
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||
estimatedTradeValueMinor: integer("estimated_trade_value_minor")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pricingObservations = pgTable('pricing_observations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id').notNull().references(() => products.id),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
providerId: text('provider_id').notNull(),
|
||||
priceMinor: integer('price_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
sourceIdentifier: text('source_identifier').notNull(),
|
||||
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
|
||||
sourceUrl: text('source_url'),
|
||||
qualityScore: integer('quality_score'),
|
||||
isOutlier: boolean('is_outlier').default(false).notNull(),
|
||||
outlierReason: text('outlier_reason'),
|
||||
observedAt: timestamp('observed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
...timestamps,
|
||||
}, (table) => [index('pricing_observations_inventory_item_id_index').on(table.inventoryItemId)]);
|
||||
|
||||
export const marketValuations = pgTable('market_valuations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
valueMinor: integer('value_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
strategy: text('strategy').notNull(),
|
||||
confidence: text('confidence').notNull(),
|
||||
sourceCount: integer('source_count').notNull(),
|
||||
calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const 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',
|
||||
export const pricingObservations = pgTable(
|
||||
"pricing_observations",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
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(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
externalIdentifier: text("external_identifier"),
|
||||
searchQuery: text("search_query"),
|
||||
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('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(
|
||||
'image_derivative_jobs',
|
||||
"image_derivative_jobs",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryImageId: uuid('inventory_image_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryImageId: uuid("inventory_image_id")
|
||||
.notNull()
|
||||
.references(() => inventoryImages.id),
|
||||
sourceKey: text('source_key').notNull(),
|
||||
status: text('status').default('PENDING').notNull(),
|
||||
derivatives: jsonb('derivatives').default([]).notNull(),
|
||||
error: text('error'),
|
||||
sourceKey: text("source_key").notNull(),
|
||||
status: text("status").default("PENDING").notNull(),
|
||||
derivatives: jsonb("derivatives").default([]).notNull(),
|
||||
error: text("error"),
|
||||
...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', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productName: text('product_name').notNull(),
|
||||
categoryId: uuid('category_id').notNull(),
|
||||
conditionId: uuid('condition_id')
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
askingPriceMinor: integer('asking_price_minor').notNull(),
|
||||
status: listingStatus('status').default('DRAFT').notNull(),
|
||||
notes: text('notes'),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||
index('listings_status_index').on(table.status),
|
||||
]);
|
||||
export const listings = pgTable(
|
||||
"listings",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productName: text("product_name").notNull(),
|
||||
categoryId: uuid("category_id").notNull(),
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
askingPriceMinor: integer("asking_price_minor").notNull(),
|
||||
status: listingStatus("status").default("DRAFT").notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index("listings_inventory_item_id_index").on(table.inventoryItemId),
|
||||
index("listings_status_index").on(table.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const sales = pgTable('sales', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
export const sales = pgTable("sales", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
status: text('status').default('COMPLETED').notNull(),
|
||||
saleDate: text('sale_date').notNull(),
|
||||
saleLocation: text('sale_location'),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(),
|
||||
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(),
|
||||
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(),
|
||||
taxesMinor: integer('taxes_minor').default(0).notNull(),
|
||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||
buyerName: text('buyer_name'),
|
||||
buyerContact: text('buyer_contact'),
|
||||
notes: text('notes'),
|
||||
voidReason: text('void_reason'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
status: text("status").default("COMPLETED").notNull(),
|
||||
saleDate: text("sale_date").notNull(),
|
||||
saleLocation: text("sale_location"),
|
||||
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
platformFeesMinor: integer("platform_fees_minor").default(0).notNull(),
|
||||
paymentProcessingFeesMinor: integer("payment_processing_fees_minor")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
shippingCostMinor: integer("shipping_cost_minor").default(0).notNull(),
|
||||
taxesMinor: integer("taxes_minor").default(0).notNull(),
|
||||
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||
buyerName: text("buyer_name"),
|
||||
buyerContact: text("buyer_contact"),
|
||||
notes: text("notes"),
|
||||
voidReason: text("void_reason"),
|
||||
voidedAt: timestamp("voided_at", { withTimezone: true }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const saleItems = pgTable(
|
||||
'sale_items',
|
||||
"sale_items",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
saleId: uuid('sale_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
saleId: uuid("sale_id")
|
||||
.notNull()
|
||||
.references(() => sales.id),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
costBasisMinor: integer('cost_basis_minor').notNull(),
|
||||
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||
costBasisMinor: integer("cost_basis_minor").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId),
|
||||
index('sale_items_sale_id_index').on(table.saleId),
|
||||
uniqueIndex("sale_items_inventory_item_id_unique").on(
|
||||
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) {
|
||||
return {
|
||||
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.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' };
|
||||
const [created] = await transaction.insert(acquisitions).values(acquisition).returning();
|
||||
return { status: 'CREATED', acquisition: created };
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const [created] = await transaction
|
||||
.insert(acquisitions)
|
||||
.values(acquisition)
|
||||
.returning();
|
||||
return { status: "CREATED", acquisition: created };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -22,11 +34,14 @@ export function createAcquisitionRepository(db) {
|
||||
return db
|
||||
.select({ acquisition: acquisitions })
|
||||
.from(acquisitions)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, acquisitions.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(acquisitions.createdAt))
|
||||
.then((rows) => rows.map(({ acquisition }) => acquisition));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,64 @@
|
||||
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition';
|
||||
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition';
|
||||
import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/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) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminAcquisitionRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
acquisitionService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (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;
|
||||
});
|
||||
|
||||
app.get('/acquisitions', async (request) => ({
|
||||
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto),
|
||||
app.get("/acquisitions", async (request) => ({
|
||||
data: (
|
||||
await acquisitionService.list({ sellerId: request.admin.sellerId })
|
||||
).map(toAdminAcquisitionDto),
|
||||
}));
|
||||
|
||||
app.post('/acquisitions', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/acquisitions", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createAcquisitionSchema.safeParse(request.body);
|
||||
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) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import {
|
||||
createAcquisition,
|
||||
calculateAcquisitionCostBasis,
|
||||
} from "../domain/acquisition.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createAcquisitionService({ acquisitionRepository }) {
|
||||
return {
|
||||
async create({ sellerId, ...input }) {
|
||||
const acquisition = createAcquisition(input);
|
||||
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId });
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await acquisitionRepository.createForSeller({
|
||||
acquisition,
|
||||
sellerId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
dashboardService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (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;
|
||||
});
|
||||
|
||||
app.get('/dashboard', async (request) => ({
|
||||
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
|
||||
app.get("/dashboard", async (request) => ({
|
||||
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 {
|
||||
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),
|
||||
inventoryItemService.listBySellerId(sellerId),
|
||||
listingService.list({ sellerId }),
|
||||
@@ -11,36 +27,51 @@ export function createDashboardService({ productService, inventoryItemService, l
|
||||
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
|
||||
]);
|
||||
|
||||
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
|
||||
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
|
||||
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation]));
|
||||
const activeListings = listings.filter(({ status }) =>
|
||||
["AVAILABLE", "PENDING"].includes(status),
|
||||
);
|
||||
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 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 {
|
||||
counts: {
|
||||
products: products.length,
|
||||
inventory: inventory.length,
|
||||
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
|
||||
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
|
||||
activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
|
||||
.length,
|
||||
soldInventory: inventory.filter(({ status }) => status === "SOLD")
|
||||
.length,
|
||||
availableListings: activeListings.length,
|
||||
sales: completedSales.length,
|
||||
acquisitions: acquisitions.length,
|
||||
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
|
||||
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length,
|
||||
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length,
|
||||
missingImages: listings.filter(({ imageCount }) => imageCount < 1)
|
||||
.length,
|
||||
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
|
||||
.length,
|
||||
stalePricing: valuations.filter(
|
||||
({ calculatedAt }) =>
|
||||
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
|
||||
).length,
|
||||
failedPricingJobs,
|
||||
},
|
||||
financials: {
|
||||
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
|
||||
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
|
||||
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
|
||||
totalProfitMinor: total(completedSales, 'profitMinor'),
|
||||
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'),
|
||||
totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
|
||||
totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
|
||||
totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
|
||||
totalProfitMinor: total(completedSales, "profitMinor"),
|
||||
totalMarketValueMinor: total(
|
||||
[...valuationByItem.values()],
|
||||
"valueMinor",
|
||||
),
|
||||
},
|
||||
recentSales: completedSales.slice(0, 5),
|
||||
recentAcquisitions: acquisitions.slice(0, 5),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export async function registerPublicLookupRoutes(app, options) {
|
||||
const { catalogLookupRepository } = options;
|
||||
|
||||
app.get('/public/lookups/categories', async () => ({
|
||||
app.get("/public/lookups/categories", async () => ({
|
||||
data: await catalogLookupRepository.listCategories(),
|
||||
}));
|
||||
|
||||
app.get('/public/lookups/conditions', async () => ({
|
||||
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })),
|
||||
app.get("/public/lookups/conditions", async () => ({
|
||||
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 {
|
||||
conditions,
|
||||
@@ -7,12 +17,15 @@ import {
|
||||
marketValueOverrides,
|
||||
marketValuations,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createListingRepository(db) {
|
||||
return {
|
||||
async create(listing) {
|
||||
const [createdListing] = await db.insert(listings).values(listing).returning();
|
||||
const [createdListing] = await db
|
||||
.insert(listings)
|
||||
.values(listing)
|
||||
.returning();
|
||||
return createdListing;
|
||||
},
|
||||
|
||||
@@ -28,18 +41,28 @@ export function createListingRepository(db) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.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) {
|
||||
const results = await db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(listings.createdAt));
|
||||
@@ -49,7 +72,7 @@ export function createListingRepository(db) {
|
||||
|
||||
async searchPublic(filters = {}) {
|
||||
const {
|
||||
text = '',
|
||||
text = "",
|
||||
category,
|
||||
conditionId,
|
||||
minPriceMinor,
|
||||
@@ -57,35 +80,40 @@ export function createListingRepository(db) {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
includeSold = false,
|
||||
sort = 'recent',
|
||||
sort = "recent",
|
||||
} = filters;
|
||||
|
||||
const conditions = [
|
||||
inArray(
|
||||
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));
|
||||
}
|
||||
|
||||
if (typeof conditionId === 'string' && conditionId.trim().length > 0) {
|
||||
if (typeof conditionId === "string" && conditionId.trim().length > 0) {
|
||||
conditions.push(eq(inventoryItems.conditionId, conditionId));
|
||||
}
|
||||
|
||||
if (typeof minPriceMinor === 'number') {
|
||||
if (typeof minPriceMinor === "number") {
|
||||
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
|
||||
}
|
||||
|
||||
if (typeof maxPriceMinor === 'number') {
|
||||
if (typeof maxPriceMinor === "number") {
|
||||
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
|
||||
}
|
||||
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
const searchTerms = text.split(' ').filter(Boolean);
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
const searchTerms = text.split(" ").filter(Boolean);
|
||||
|
||||
if (searchTerms.length > 0) {
|
||||
conditions.push(
|
||||
@@ -105,28 +133,35 @@ export function createListingRepository(db) {
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const sortOrder = sort === 'price_low'
|
||||
? listings.askingPriceMinor
|
||||
: sort === 'price_high'
|
||||
? desc(listings.askingPriceMinor)
|
||||
: desc(listings.createdAt);
|
||||
const sortOrder =
|
||||
sort === "price_low"
|
||||
? listings.askingPriceMinor
|
||||
: sort === "price_high"
|
||||
? desc(listings.askingPriceMinor)
|
||||
: desc(listings.createdAt);
|
||||
|
||||
const [countResult, results] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(...conditions)),
|
||||
db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(sortOrder, desc(listings.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(sortOrder, desc(listings.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -139,22 +174,40 @@ export function createListingRepository(db) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, condition: conditions })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
|
||||
.where(and(
|
||||
eq(listings.id, listingId),
|
||||
inArray(listings.status, ['AVAILABLE', 'PENDING']),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
));
|
||||
.where(
|
||||
and(
|
||||
eq(listings.id, listingId),
|
||||
inArray(listings.status, ["AVAILABLE", "PENDING"]),
|
||||
eq(inventoryItems.status, "ACTIVE"),
|
||||
),
|
||||
);
|
||||
|
||||
if (!result) return null;
|
||||
const [override] = await db.select().from(marketValueOverrides)
|
||||
.where(eq(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);
|
||||
const [override] = await db
|
||||
.select()
|
||||
.from(marketValueOverrides)
|
||||
.where(
|
||||
eq(
|
||||
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 {
|
||||
...result.listing,
|
||||
conditionName: result.condition.name,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.`);
|
||||
}
|
||||
|
||||
@@ -19,28 +19,30 @@ export function createPricingObservation({
|
||||
inventoryItemId,
|
||||
providerId,
|
||||
priceMinor,
|
||||
currency = 'USD',
|
||||
currency = "USD",
|
||||
sourceIdentifier,
|
||||
conditionId,
|
||||
sourceUrl,
|
||||
qualityScore,
|
||||
}) {
|
||||
return {
|
||||
productId: requiredText(productId, 'Product ID'),
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
providerId: requiredText(providerId, 'Provider ID'),
|
||||
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
|
||||
currency: requiredText(currency, 'Currency'),
|
||||
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
|
||||
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
|
||||
productId: requiredText(productId, "Product ID"),
|
||||
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||
providerId: requiredText(providerId, "Provider ID"),
|
||||
priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
|
||||
currency: requiredText(currency, "Currency"),
|
||||
sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
|
||||
conditionId: requiredText(conditionId, "Condition ID"),
|
||||
...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
|
||||
? { sourceUrl: sourceUrl.trim() }
|
||||
: {}),
|
||||
...(typeof qualityScore === "number" ? { qualityScore } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateMedianPriceMinor(prices) {
|
||||
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);
|
||||
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
|
||||
if (!Array.isArray(prices) || prices.length < 4) return new Set();
|
||||
const sorted = [...prices].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
const median =
|
||||
sorted.length % 2
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
const lowerFence = 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() {
|
||||
return createPricingProvider({
|
||||
id: 'fixture-provider',
|
||||
id: "fixture-provider",
|
||||
async observe({ productId, inventoryItemId, conditionId }) {
|
||||
return [{
|
||||
productId,
|
||||
inventoryItemId,
|
||||
conditionId,
|
||||
priceMinor: 2500,
|
||||
currency: 'USD',
|
||||
sourceIdentifier: `fixture-${inventoryItemId}`,
|
||||
qualityScore: 50,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
productId,
|
||||
inventoryItemId,
|
||||
conditionId,
|
||||
priceMinor: 2500,
|
||||
currency: "USD",
|
||||
sourceIdentifier: `fixture-${inventoryItemId}`,
|
||||
qualityScore: 50,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export function createPricingProvider({ id, observe }) {
|
||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||
throw new Error('Pricing provider id is required.');
|
||||
if (typeof id !== "string" || id.trim().length === 0) {
|
||||
throw new Error("Pricing provider id is required.");
|
||||
}
|
||||
|
||||
if (typeof observe !== 'function') {
|
||||
throw new TypeError('Pricing provider observe function is required.');
|
||||
if (typeof observe !== "function") {
|
||||
throw new TypeError("Pricing provider observe function is required.");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
|
||||
async observe(input) {
|
||||
const observations = await observe(input);
|
||||
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;
|
||||
},
|
||||
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
get(providerId) {
|
||||
|
||||
@@ -1,95 +1,181 @@
|
||||
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) {
|
||||
const maxAttempts = 3;
|
||||
return {
|
||||
async create(job) {
|
||||
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
|
||||
const [created] = await db
|
||||
.insert(pricingRefreshJobs)
|
||||
.values(job)
|
||||
.returning();
|
||||
return created;
|
||||
},
|
||||
|
||||
async claimNext() {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [job] = await transaction.select().from(pricingRefreshJobs)
|
||||
.where(and(
|
||||
eq(pricingRefreshJobs.status, 'PENDING'),
|
||||
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
|
||||
))
|
||||
const [job] = await transaction
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.status, "PENDING"),
|
||||
or(
|
||||
isNull(pricingRefreshJobs.nextAttemptAt),
|
||||
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(pricingRefreshJobs.createdAt))
|
||||
.limit(1)
|
||||
.for('update', { skipLocked: true });
|
||||
.for("update", { skipLocked: true });
|
||||
if (!job) return null;
|
||||
const [claimed] = await transaction.update(pricingRefreshJobs)
|
||||
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
|
||||
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
|
||||
const [claimed] = await transaction
|
||||
.update(pricingRefreshJobs)
|
||||
.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();
|
||||
return claimed ?? null;
|
||||
});
|
||||
},
|
||||
|
||||
async findByIdForSeller(id, sellerId) {
|
||||
const [job] = await db.select().from(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId)));
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
return job ?? null;
|
||||
},
|
||||
|
||||
async countFailedForSeller(sellerId) {
|
||||
const rows = await db.select({ id: pricingRefreshJobs.id })
|
||||
const rows = await db
|
||||
.select({ id: pricingRefreshJobs.id })
|
||||
.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;
|
||||
},
|
||||
|
||||
async listForSeller(sellerId) {
|
||||
return db.select().from(pricingRefreshJobs)
|
||||
return db
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(eq(pricingRefreshJobs.sellerId, sellerId))
|
||||
.orderBy(desc(pricingRefreshJobs.createdAt));
|
||||
},
|
||||
|
||||
async retryForSeller(id, sellerId) {
|
||||
const [job] = await db.update(pricingRefreshJobs)
|
||||
.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)))
|
||||
const [job] = await db
|
||||
.update(pricingRefreshJobs)
|
||||
.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();
|
||||
return job ?? null;
|
||||
},
|
||||
|
||||
async complete(id, observations, valuation) {
|
||||
const [completed] = await db.update(pricingRefreshJobs)
|
||||
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
|
||||
const [completed] = await db
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: "COMPLETED",
|
||||
observations,
|
||||
valuation,
|
||||
error: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return completed ?? null;
|
||||
},
|
||||
|
||||
async fail(id, message) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [current] = await transaction.select().from(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
|
||||
const [current] = await transaction
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||
),
|
||||
);
|
||||
if (!current) return null;
|
||||
const attemptCount = current.attemptCount + 1;
|
||||
const retryable = attemptCount < maxAttempts;
|
||||
const [updated] = await transaction.update(pricingRefreshJobs)
|
||||
const [updated] = await transaction
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: retryable ? 'PENDING' : 'FAILED',
|
||||
status: retryable ? "PENDING" : "FAILED",
|
||||
error: message,
|
||||
attemptCount,
|
||||
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();
|
||||
return updated ?? null;
|
||||
});
|
||||
},
|
||||
|
||||
async purgeCompletedBefore(sellerId, cutoff) {
|
||||
const deleted = await db.delete(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
|
||||
const deleted = await db
|
||||
.delete(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
eq(pricingRefreshJobs.status, "COMPLETED"),
|
||||
lte(pricingRefreshJobs.updatedAt, cutoff),
|
||||
),
|
||||
)
|
||||
.returning({ id: pricingRefreshJobs.id });
|
||||
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 { identifyOutlierPrices } from '../domain/pricing-observation.js';
|
||||
import {
|
||||
inventoryItems,
|
||||
marketValueOverrides,
|
||||
marketValuations,
|
||||
pricingObservations,
|
||||
products,
|
||||
securityAuditEvents,
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
|
||||
|
||||
export function createPricingRepository(db) {
|
||||
return {
|
||||
@@ -10,77 +17,165 @@ export function createPricingRepository(db) {
|
||||
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' };
|
||||
const [created] = await db.insert(pricingObservations).values(observation).returning();
|
||||
return { status: 'CREATED', observation: created };
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, observation.inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem || ownedItem.productId !== observation.productId)
|
||||
return { status: "NOT_FOUND" };
|
||||
const [created] = await db
|
||||
.insert(pricingObservations)
|
||||
.values(observation)
|
||||
.returning();
|
||||
return { status: "CREATED", observation: created };
|
||||
},
|
||||
|
||||
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) {
|
||||
async calculateForSeller({
|
||||
inventoryItemId,
|
||||
sellerId,
|
||||
strategy = "MEDIAN",
|
||||
}) {
|
||||
const [ownedItem] = await db
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const observations = await db.select().from(pricingObservations)
|
||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false)))
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const observations = await db
|
||||
.select()
|
||||
.from(pricingObservations)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingObservations.inventoryItemId, inventoryItemId),
|
||||
eq(pricingObservations.isOutlier, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(pricingObservations.observedAt));
|
||||
if (!observations.length) return { status: 'NO_DATA' };
|
||||
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right);
|
||||
if (!observations.length) return { status: "NO_DATA" };
|
||||
const prices = observations
|
||||
.map(({ priceMinor }) => priceMinor)
|
||||
.sort((left, right) => left - right);
|
||||
const middle = Math.floor(prices.length / 2);
|
||||
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2);
|
||||
const [valuation] = await db.insert(marketValuations).values({
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency: 'USD',
|
||||
strategy,
|
||||
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM',
|
||||
sourceCount: observations.length,
|
||||
}).returning();
|
||||
return { status: 'CALCULATED', valuation, observations };
|
||||
const valueMinor =
|
||||
prices.length % 2
|
||||
? prices[middle]
|
||||
: Math.round((prices[middle - 1] + prices[middle]) / 2);
|
||||
const [valuation] = await db
|
||||
.insert(marketValuations)
|
||||
.values({
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency: "USD",
|
||||
strategy,
|
||||
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
|
||||
sourceCount: observations.length,
|
||||
})
|
||||
.returning();
|
||||
return { status: "CALCULATED", valuation, observations };
|
||||
},
|
||||
|
||||
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)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, marketValuations.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(marketValuations.calculatedAt));
|
||||
const latest = new Map();
|
||||
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()];
|
||||
},
|
||||
|
||||
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))
|
||||
.where(and(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));
|
||||
.where(
|
||||
and(
|
||||
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 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) {
|
||||
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
|
||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
|
||||
await db
|
||||
.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) => {
|
||||
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))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
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 };
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
return {
|
||||
async createForSeller(mapping, sellerId) {
|
||||
const [ownedProduct] = await db.select({ id: products.id }).from(products)
|
||||
.where(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 };
|
||||
const [ownedProduct] = await db
|
||||
.select({ id: products.id })
|
||||
.from(products)
|
||||
.where(
|
||||
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) {
|
||||
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
||||
return db
|
||||
.select({ mapping: pricingProviderMappings })
|
||||
.from(pricingProviderMappings)
|
||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||
.where(and(
|
||||
eq(pricingProviderMappings.productId, productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
eq(pricingProviderMappings.productId, productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map(({ mapping }) => mapping));
|
||||
},
|
||||
|
||||
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
|
||||
const conditions = [
|
||||
eq(pricingProviderMappings.productId, productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||
];
|
||||
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
||||
if (providerId)
|
||||
conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||
const [row] = await db
|
||||
.select({ mapping: pricingProviderMappings })
|
||||
.from(pricingProviderMappings)
|
||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||
.where(and(...conditions));
|
||||
return row?.mapping ?? null;
|
||||
},
|
||||
|
||||
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))
|
||||
.where(and(
|
||||
eq(pricingProviderMappings.id, id),
|
||||
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() })
|
||||
.where(
|
||||
and(
|
||||
eq(pricingProviderMappings.id, id),
|
||||
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(),
|
||||
})
|
||||
.where(eq(pricingProviderMappings.id, id))
|
||||
.returning();
|
||||
return { status: 'DISABLED', mapping: updated };
|
||||
return { status: "DISABLED", mapping: updated };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,85 @@
|
||||
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
|
||||
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
|
||||
import {
|
||||
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) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminPricingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
pricingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (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;
|
||||
});
|
||||
|
||||
app.post('/pricing/observations', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/pricing/observations", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createPricingObservationSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId });
|
||||
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) });
|
||||
const observation = await pricingService.addObservation({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ data: toAdminPricingObservationDto(observation) });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/valuation', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
|
||||
app.post("/inventory/:id/valuation", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
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) });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/valuation/override', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/inventory/:id/valuation/override", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createMarketValueOverrideSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const override = await pricingService.override({
|
||||
@@ -54,8 +91,11 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
return reply.code(201).send({ data: override });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const { providerId, productId, conditionId } = request.body ?? {};
|
||||
const job = await pricingService.enqueueRefresh({
|
||||
sellerId: request.admin.sellerId,
|
||||
@@ -64,11 +104,16 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
productId,
|
||||
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) => {
|
||||
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
|
||||
app.get("/jobs/:id", async (request) => {
|
||||
const job = await pricingService.getJob({
|
||||
sellerId: request.admin.sellerId,
|
||||
jobId: request.params.id,
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
id: job.id,
|
||||
@@ -81,16 +126,31 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/jobs', async (request) => ({
|
||||
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).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.get("/jobs", async (request) => ({
|
||||
data: (
|
||||
await pricingService.listJobs({ sellerId: request.admin.sellerId })
|
||||
).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) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], 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 } });
|
||||
app.post("/jobs/:id/retry", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
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 { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping';
|
||||
import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/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) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminProviderMappingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
providerMappingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (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;
|
||||
});
|
||||
|
||||
app.get('/products/:id/provider-mappings', async (request) => ({
|
||||
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto),
|
||||
app.get("/products/:id/provider-mappings", async (request) => ({
|
||||
data: (
|
||||
await providerMappingService.list({
|
||||
productId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
})
|
||||
).map(toAdminProviderMappingDto),
|
||||
}));
|
||||
|
||||
app.post('/products/:id/provider-mappings', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/products/:id/provider-mappings", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createProviderMappingSchema.safeParse(request.body);
|
||||
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) });
|
||||
});
|
||||
|
||||
app.post('/provider-mappings/:id/disable', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId });
|
||||
app.post("/provider-mappings/:id/disable", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
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) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,66 +1,184 @@
|
||||
import { createPricingObservation } from '../domain/pricing-observation.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createPricingObservation } from "../domain/pricing-observation.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
|
||||
export function createPricingService({
|
||||
pricingRepository,
|
||||
pricingProviderRegistry,
|
||||
pricingRefreshJobRepository,
|
||||
providerMappingRepository,
|
||||
}) {
|
||||
return {
|
||||
async addObservation({ sellerId, ...input }) {
|
||||
const observation = createPricingObservation(input);
|
||||
const result = await pricingRepository.addObservationForSeller({ observation, sellerId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await pricingRepository.addObservationForSeller({
|
||||
observation,
|
||||
sellerId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
return result.observation;
|
||||
},
|
||||
|
||||
async markOutliers({ sellerId, inventoryItemId }) {
|
||||
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await pricingRepository.markOutliersForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async override({ sellerId, actorUserId, inventoryItemId, 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);
|
||||
async override({
|
||||
sellerId,
|
||||
actorUserId,
|
||||
inventoryItemId,
|
||||
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;
|
||||
},
|
||||
|
||||
async calculate({ sellerId, inventoryItemId }) {
|
||||
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
|
||||
await pricingRepository.markOutliersForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
const result = await pricingRepository.calculateForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
if (result.status === "NO_DATA")
|
||||
throw createApplicationError(
|
||||
"PRICING_DATA_UNAVAILABLE",
|
||||
"No pricing observations are available.",
|
||||
404,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
||||
async refresh({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
providerId,
|
||||
productId,
|
||||
conditionId,
|
||||
}) {
|
||||
const provider = pricingProviderRegistry?.get(providerId);
|
||||
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;
|
||||
try {
|
||||
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
|
||||
providerObservations = await provider.observe({
|
||||
inventoryItemId,
|
||||
productId,
|
||||
conditionId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
|
||||
throw createApplicationError(
|
||||
"PRICING_PROVIDER_FAILED",
|
||||
error.message,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const observations = [];
|
||||
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 }) {
|
||||
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);
|
||||
async enqueueRefresh({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
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({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
providerId: mapping.providerId,
|
||||
externalIdentifier: mapping.externalIdentifier,
|
||||
searchQuery: mapping.searchQuery,
|
||||
productId,
|
||||
conditionId,
|
||||
});
|
||||
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
||||
},
|
||||
|
||||
async getJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId);
|
||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
|
||||
const job = await pricingRefreshJobRepository?.findByIdForSeller(
|
||||
jobId,
|
||||
sellerId,
|
||||
);
|
||||
if (!job)
|
||||
throw createApplicationError(
|
||||
"PRICING_JOB_NOT_FOUND",
|
||||
"Pricing job was not found.",
|
||||
404,
|
||||
);
|
||||
return job;
|
||||
},
|
||||
|
||||
@@ -78,9 +204,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
||||
},
|
||||
|
||||
async retryJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
|
||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(
|
||||
jobId,
|
||||
sellerId,
|
||||
);
|
||||
if (!job)
|
||||
throw createApplicationError(
|
||||
"PRICING_JOB_NOT_RETRYABLE",
|
||||
"Pricing job was not found or is not failed.",
|
||||
409,
|
||||
);
|
||||
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 }) {
|
||||
return {
|
||||
async create({ sellerId, ...input }) {
|
||||
const result = await providerMappingRepository.createForSeller(input, sellerId);
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404);
|
||||
const result = await providerMappingRepository.createForSeller(
|
||||
input,
|
||||
sellerId,
|
||||
);
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"PRODUCT_NOT_FOUND",
|
||||
"Product was not found.",
|
||||
404,
|
||||
);
|
||||
return result.mapping;
|
||||
},
|
||||
async list({ productId, sellerId }) {
|
||||
return providerMappingRepository.listForSeller(productId, sellerId);
|
||||
},
|
||||
async disable({ id, sellerId }) {
|
||||
const result = await providerMappingRepository.disableForSeller(id, sellerId);
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404);
|
||||
const result = await providerMappingRepository.disableForSeller(
|
||||
id,
|
||||
sellerId,
|
||||
);
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"PROVIDER_MAPPING_NOT_FOUND",
|
||||
"Provider mapping was not found.",
|
||||
404,
|
||||
);
|
||||
return result.mapping;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.`);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
|
||||
const [, year, month, day] = match;
|
||||
const parsedDate = new Date(`${date}T00:00:00.000Z`);
|
||||
if (
|
||||
Number.isNaN(parsedDate.getTime())
|
||||
|| parsedDate.getUTCFullYear() !== Number(year)
|
||||
|| parsedDate.getUTCMonth() + 1 !== Number(month)
|
||||
|| parsedDate.getUTCDate() !== Number(day)
|
||||
Number.isNaN(parsedDate.getTime()) ||
|
||||
parsedDate.getUTCFullYear() !== Number(year) ||
|
||||
parsedDate.getUTCMonth() + 1 !== Number(month) ||
|
||||
parsedDate.getUTCDate() !== Number(day)
|
||||
) {
|
||||
throw new Error(`${fieldName} must be a valid ISO date.`);
|
||||
}
|
||||
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
|
||||
}
|
||||
|
||||
function requireCurrency(value) {
|
||||
const currency = requiredText(value, 'Currency').toUpperCase();
|
||||
const currency = requiredText(value, "Currency").toUpperCase();
|
||||
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;
|
||||
@@ -58,7 +58,7 @@ export function createSale({
|
||||
saleDate,
|
||||
saleLocation,
|
||||
soldPriceMinor,
|
||||
currency = 'USD',
|
||||
currency = "USD",
|
||||
platformFeesMinor = 0,
|
||||
paymentProcessingFeesMinor = 0,
|
||||
shippingCostMinor = 0,
|
||||
@@ -69,42 +69,55 @@ export function createSale({
|
||||
notes,
|
||||
}) {
|
||||
return {
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
saleDate: requireIsoDate(saleDate, 'Sale date'),
|
||||
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
|
||||
sellerId: requiredText(sellerId, "Seller ID"),
|
||||
saleDate: requireIsoDate(saleDate, "Sale date"),
|
||||
...(typeof saleLocation === "string" && saleLocation.trim().length > 0
|
||||
? { saleLocation: saleLocation.trim() }
|
||||
: {}),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||
currency: requireCurrency(currency),
|
||||
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
|
||||
platformFeesMinor: requireNonNegativeMinorUnit(
|
||||
platformFeesMinor,
|
||||
"Platform fees",
|
||||
),
|
||||
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
||||
paymentProcessingFeesMinor,
|
||||
'Payment processing fees',
|
||||
"Payment processing fees",
|
||||
),
|
||||
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'),
|
||||
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'),
|
||||
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'),
|
||||
...(typeof buyerName === 'string' && buyerName.trim().length > 0
|
||||
shippingCostMinor: requireNonNegativeMinorUnit(
|
||||
shippingCostMinor,
|
||||
"Shipping cost",
|
||||
),
|
||||
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
|
||||
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
|
||||
...(typeof buyerName === "string" && buyerName.trim().length > 0
|
||||
? { buyerName: buyerName.trim() }
|
||||
: {}),
|
||||
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0
|
||||
...(typeof buyerContact === "string" && buyerContact.trim().length > 0
|
||||
? { 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 }) {
|
||||
return {
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
|
||||
export function createSaleItem({
|
||||
saleId,
|
||||
inventoryItemId,
|
||||
soldPriceMinor,
|
||||
costBasisMinor,
|
||||
}) {
|
||||
return {
|
||||
saleId: requiredText(saleId, 'Sale ID'),
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
saleId: requiredText(saleId, "Sale ID"),
|
||||
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
|
||||
};
|
||||
}
|
||||
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
|
||||
taxesMinor = 0,
|
||||
otherFeesMinor = 0,
|
||||
}) {
|
||||
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price');
|
||||
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees');
|
||||
const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
|
||||
const platformFees = requireNonNegativeMinorUnit(
|
||||
platformFeesMinor,
|
||||
"Platform fees",
|
||||
);
|
||||
const processingFees = requireNonNegativeMinorUnit(
|
||||
paymentProcessingFeesMinor,
|
||||
'Payment processing fees',
|
||||
"Payment processing fees",
|
||||
);
|
||||
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost');
|
||||
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes');
|
||||
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees');
|
||||
const shipping = requireNonNegativeMinorUnit(
|
||||
shippingCostMinor,
|
||||
"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 {
|
||||
inventoryItems,
|
||||
@@ -6,14 +6,20 @@ import {
|
||||
saleItems,
|
||||
sales,
|
||||
securityAuditEvents,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createSaleRepository(db) {
|
||||
return {
|
||||
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
|
||||
return this.createForInventoryItems({
|
||||
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 })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
||||
eq(products.sellerId, sale.sellerId),
|
||||
));
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
inventoryItems.id,
|
||||
items.map((item) => item.inventoryItemId),
|
||||
),
|
||||
eq(products.sellerId, sale.sellerId),
|
||||
),
|
||||
);
|
||||
|
||||
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' };
|
||||
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE');
|
||||
if (ownedInventoryItems.length !== items.length)
|
||||
return { status: "NOT_FOUND" };
|
||||
const unavailableItem = ownedInventoryItems.find(
|
||||
(item) => item.status !== "ACTIVE",
|
||||
);
|
||||
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 createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({
|
||||
saleId: createdSale.id,
|
||||
...item,
|
||||
}))).returning();
|
||||
const [createdSale] = await transaction
|
||||
.insert(sales)
|
||||
.values(sale)
|
||||
.returning();
|
||||
const createdSaleItems = await transaction
|
||||
.insert(saleItems)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
saleId: createdSale.id,
|
||||
...item,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
const updatedInventoryItems = await transaction
|
||||
.update(inventoryItems)
|
||||
.set({ status: 'SOLD', updatedAt: new Date() })
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
))
|
||||
.set({ status: "SOLD", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
inventoryItems.id,
|
||||
items.map((item) => item.inventoryItemId),
|
||||
),
|
||||
eq(inventoryItems.status, "ACTIVE"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return {
|
||||
status: 'CREATED',
|
||||
status: "CREATED",
|
||||
sale: createdSale,
|
||||
saleItems: createdSaleItems,
|
||||
saleItem: createdSaleItems[0],
|
||||
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
|
||||
})
|
||||
.from(sales)
|
||||
.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))
|
||||
.where(eq(sales.sellerId, sellerId))
|
||||
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
|
||||
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
|
||||
productNames: [],
|
||||
};
|
||||
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);
|
||||
grouped.set(row.sale.id, current);
|
||||
}
|
||||
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
|
||||
...sale,
|
||||
saleItem: sale.saleItems[0],
|
||||
inventoryItem: sale.inventoryItems[0],
|
||||
productName: sale.productNames.join(', '),
|
||||
productName: sale.productNames.join(", "),
|
||||
}));
|
||||
},
|
||||
|
||||
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const saleRecords = await transaction
|
||||
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status })
|
||||
.select({
|
||||
sale: sales,
|
||||
saleItem: saleItems,
|
||||
inventoryStatus: inventoryItems.status,
|
||||
})
|
||||
.from(sales)
|
||||
.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))
|
||||
.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[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' };
|
||||
if (!saleRecords.length) return { status: "NOT_FOUND" };
|
||||
if (saleRecords[0].sale.status === "VOIDED")
|
||||
return { status: "ALREADY_VOIDED" };
|
||||
const saleRecord = saleRecords[0];
|
||||
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId);
|
||||
const inventoryItemIds = saleRecords.map(
|
||||
({ saleItem }) => saleItem.inventoryItemId,
|
||||
);
|
||||
|
||||
const [updatedSale] = await transaction
|
||||
.update(sales)
|
||||
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED')))
|
||||
.set({
|
||||
status: "VOIDED",
|
||||
voidReason: reason,
|
||||
voidedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
|
||||
.returning();
|
||||
const [updatedInventoryItem] = await transaction
|
||||
.update(inventoryItems)
|
||||
.set({ status: 'ACTIVE', updatedAt: new Date() })
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, inventoryItemIds),
|
||||
eq(inventoryItems.status, 'SOLD'),
|
||||
))
|
||||
.set({ status: "ACTIVE", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
inArray(inventoryItems.id, inventoryItemIds),
|
||||
eq(inventoryItems.status, "SOLD"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
await transaction.insert(securityAuditEvents).values({
|
||||
userId: actorUserId,
|
||||
event: 'SALE_VOIDED',
|
||||
event: "SALE_VOIDED",
|
||||
metadata: { saleId, inventoryItemIds, reason },
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'VOIDED',
|
||||
status: "VOIDED",
|
||||
sale: updatedSale,
|
||||
saleItems: saleRecords.map(({ saleItem }) => saleItem),
|
||||
saleItem: saleRecord.saleItem,
|
||||
@@ -139,4 +198,4 @@ export function createSaleRepository(db) {
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,64 @@
|
||||
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
|
||||
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
|
||||
import { toAdminSaleDto } from "@pokemon-inventory/contracts/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) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerAdminSaleRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
csrfCookieName = "pokemon_inventory_csrf",
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
saleService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.post('/sales', async (request, reply) => {
|
||||
app.post("/sales", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
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);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
|
||||
return reply.code(201).send({ data: toAdminSaleDto(sale) });
|
||||
});
|
||||
|
||||
app.get('/sales', async (request) => ({
|
||||
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
|
||||
app.get("/sales", async (request) => ({
|
||||
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?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = voidSaleSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
@@ -76,4 +95,4 @@ export async function registerAdminSaleRoutes(app, options) {
|
||||
});
|
||||
return reply.code(200).send({ data: toAdminSaleDto(sale) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import {
|
||||
calculateNetSaleProceeds,
|
||||
createSale,
|
||||
validateSaleItem,
|
||||
} from "../domain/sale.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createSaleService({ saleRepository }) {
|
||||
return {
|
||||
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
|
||||
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
|
||||
async create({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
costBasisMinor,
|
||||
items,
|
||||
...input
|
||||
}) {
|
||||
const saleItems = items ?? [
|
||||
{
|
||||
inventoryItemId,
|
||||
soldPriceMinor: input.soldPriceMinor,
|
||||
costBasisMinor,
|
||||
},
|
||||
];
|
||||
const totalSoldPriceMinor = items
|
||||
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
|
||||
: input.soldPriceMinor;
|
||||
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
|
||||
const sale = createSale({
|
||||
sellerId,
|
||||
...input,
|
||||
soldPriceMinor: totalSoldPriceMinor,
|
||||
});
|
||||
const validatedItems = saleItems.map((item) => ({
|
||||
inventoryItemId: item.inventoryItemId,
|
||||
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
|
||||
costBasisMinor: validateSaleItem(item).costBasisMinor,
|
||||
}));
|
||||
const result = items
|
||||
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
|
||||
? await saleRepository.createForInventoryItems({
|
||||
sale,
|
||||
items: validatedItems,
|
||||
})
|
||||
: await saleRepository.createForInventoryItem({
|
||||
sale,
|
||||
inventoryItemId,
|
||||
costBasisMinor: validatedItems[0].costBasisMinor,
|
||||
});
|
||||
sale,
|
||||
inventoryItemId,
|
||||
costBasisMinor: validatedItems[0].costBasisMinor,
|
||||
});
|
||||
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === 'NOT_ACTIVE') {
|
||||
if (result.status === "NOT_ACTIVE") {
|
||||
throw createApplicationError(
|
||||
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
|
||||
'Inventory item is not available for sale.',
|
||||
"INVENTORY_NOT_AVAILABLE_FOR_SALE",
|
||||
"Inventory item is not available for sale.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
|
||||
},
|
||||
|
||||
async void({ saleId, sellerId, actorUserId, reason }) {
|
||||
if (typeof reason !== 'string' || reason.trim().length < 3) {
|
||||
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
|
||||
if (typeof reason !== "string" || reason.trim().length < 3) {
|
||||
throw createApplicationError(
|
||||
"SALE_VOID_REASON_REQUIRED",
|
||||
"A void reason is required.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await saleRepository.voidForSeller({
|
||||
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
|
||||
actorUserId,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"SALE_NOT_FOUND",
|
||||
"Sale was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'ALREADY_VOIDED') {
|
||||
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
|
||||
if (result.status === "ALREADY_VOIDED") {
|
||||
throw createApplicationError(
|
||||
"SALE_ALREADY_VOIDED",
|
||||
"Sale is already voided.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return withFinancialSummary(result);
|
||||
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
|
||||
}
|
||||
|
||||
function withFinancialSummary(result) {
|
||||
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
|
||||
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
|
||||
const saleItems = result.saleItems ?? [result.saleItem];
|
||||
return {
|
||||
...result,
|
||||
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) {
|
||||
if (typeof value !== 'string') {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${fieldName} must be a string.`);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@ function requiredString(value, fieldName) {
|
||||
}
|
||||
|
||||
export function normalizeSearchQuery(value) {
|
||||
return requiredString(value, 'Search query')
|
||||
return requiredString(value, "Search query")
|
||||
.trim()
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.normalize("NFKC")
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function createSearchQuery({
|
||||
query = '',
|
||||
query = "",
|
||||
category,
|
||||
conditionId,
|
||||
minPriceMinor,
|
||||
@@ -24,54 +24,68 @@ export function createSearchQuery({
|
||||
page = 1,
|
||||
limit = 20,
|
||||
includeSold = false,
|
||||
sort = 'recent',
|
||||
sort = "recent",
|
||||
}) {
|
||||
const text = normalizeSearchQuery(query);
|
||||
|
||||
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) {
|
||||
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');
|
||||
throw new Error("limit must be between 1 and 100");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof minPriceMinor !== 'undefined' &&
|
||||
typeof maxPriceMinor !== 'undefined' &&
|
||||
minPriceMinor > maxPriceMinor
|
||||
typeof minPriceMinor !== "undefined" &&
|
||||
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
||||
) {
|
||||
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)) {
|
||||
throw new Error('sort must be recent, price_low, or price_high');
|
||||
if (
|
||||
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 {
|
||||
text,
|
||||
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}),
|
||||
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}),
|
||||
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}),
|
||||
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}),
|
||||
...(typeof category === "string" && category.trim().length > 0
|
||||
? { category: category.trim() }
|
||||
: {}),
|
||||
...(typeof conditionId === "string" && conditionId.trim().length > 0
|
||||
? { conditionId: conditionId.trim() }
|
||||
: {}),
|
||||
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
|
||||
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
|
||||
page,
|
||||
limit,
|
||||
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 {
|
||||
status: includeSold ? 'ALL' : status,
|
||||
status: includeSold ? "ALL" : status,
|
||||
includeSold,
|
||||
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) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
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;
|
||||
}
|
||||
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
|
||||
export async function registerPublicSearchRoutes(app, options) {
|
||||
const { searchService } = options;
|
||||
|
||||
app.get('/public/search', async (request) => {
|
||||
if (!searchService || typeof searchService.searchPublic !== 'function') {
|
||||
app.get("/public/search", async (request) => {
|
||||
if (!searchService || typeof searchService.searchPublic !== "function") {
|
||||
throw createApplicationError(
|
||||
'SEARCH_SERVICE_UNAVAILABLE',
|
||||
'Public search is currently unavailable.',
|
||||
"SEARCH_SERVICE_UNAVAILABLE",
|
||||
"Public search is currently unavailable.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
@@ -28,14 +32,24 @@ export async function registerPublicSearchRoutes(app, options) {
|
||||
query: request.query.query,
|
||||
category: request.query.category,
|
||||
conditionId: request.query.conditionId,
|
||||
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined),
|
||||
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',
|
||||
minPriceMinor: coerceNumber(
|
||||
request.query.minPriceMinor,
|
||||
"minPriceMinor",
|
||||
undefined,
|
||||
),
|
||||
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) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user