Compare commits
2
Commits
b9ff7445a6
...
660690bf34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660690bf34 | ||
|
|
b8f089a5d9 |
@@ -71,4 +71,4 @@ jobs:
|
|||||||
- run: npm run test --workspace=@pokemon-inventory/web
|
- run: npm run test --workspace=@pokemon-inventory/web
|
||||||
- run: npm run build --workspace=@pokemon-inventory/web
|
- run: npm run build --workspace=@pokemon-inventory/web
|
||||||
- run: npm run build-storybook --workspace=@pokemon-inventory/web
|
- run: npm run build-storybook --workspace=@pokemon-inventory/web
|
||||||
- run: npm run test-storybook
|
- run: npm run test-storybook
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from "fastify";
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from "@fastify/cookie";
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from "@fastify/multipart";
|
||||||
|
|
||||||
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
|
import { registerAuthRoutes } from "../modules/auth/routes/auth-routes.js";
|
||||||
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
|
import { registerAdminInventoryRoutes } from "../modules/inventory/routes/admin-inventory-routes.js";
|
||||||
import { registerAdminLookupRoutes } from '../modules/inventory/routes/admin-lookup-routes.js';
|
import { registerAdminLookupRoutes } from "../modules/inventory/routes/admin-lookup-routes.js";
|
||||||
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
|
import { registerAdminImageRoutes } from "../modules/inventory/routes/admin-image-routes.js";
|
||||||
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js';
|
import { registerPublicInventoryRoutes } from "../modules/inventory/routes/public-inventory-routes.js";
|
||||||
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js';
|
import { registerPublicImageRoutes } from "../modules/inventory/routes/public-image-routes.js";
|
||||||
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
|
import { registerAdminListingRoutes } from "../modules/listings/routes/admin-listing-routes.js";
|
||||||
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js';
|
import { registerPublicListingRoutes } from "../modules/listings/routes/public-listing-routes.js";
|
||||||
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
|
import { registerAdminProductRoutes } from "../modules/products/routes/admin-product-routes.js";
|
||||||
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
import { registerPublicProductRoutes } from "../modules/products/routes/public-product-routes.js";
|
||||||
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
import { registerPublicSearchRoutes } from "../modules/search/routes/public-search-routes.js";
|
||||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
import { registerPublicSellerRoutes } from "../modules/sellers/routes/public-seller-routes.js";
|
||||||
import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js';
|
import { registerPublicLookupRoutes } from "../modules/inventory/routes/public-lookup-routes.js";
|
||||||
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
import { registerAdminSaleRoutes } from "../modules/sales/routes/admin-sale-routes.js";
|
||||||
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js';
|
import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/admin-acquisition-routes.js";
|
||||||
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js';
|
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
||||||
import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js';
|
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
||||||
import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js';
|
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
||||||
|
|
||||||
export function buildApp(dependencies) {
|
export function buildApp(dependencies) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
const statusCode = error.statusCode ?? 500;
|
const statusCode = error.statusCode ?? 500;
|
||||||
const message = statusCode >= 500 ? 'Internal server error.' : error.message;
|
const message =
|
||||||
|
statusCode >= 500 ? "Internal server error." : error.message;
|
||||||
|
|
||||||
if (statusCode >= 500) {
|
if (statusCode >= 500) {
|
||||||
request.log.error(error);
|
request.log.error(error);
|
||||||
@@ -34,87 +35,87 @@ export function buildApp(dependencies) {
|
|||||||
|
|
||||||
return reply.code(statusCode).send({
|
return reply.code(statusCode).send({
|
||||||
error: {
|
error: {
|
||||||
code: error.code ?? 'INTERNAL_SERVER_ERROR',
|
code: error.code ?? "INTERNAL_SERVER_ERROR",
|
||||||
message,
|
message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async () => ({ data: { status: 'ok' } }));
|
app.get("/health", async () => ({ data: { status: "ok" } }));
|
||||||
app.register(cookie);
|
app.register(cookie);
|
||||||
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
||||||
app.register(registerAuthRoutes, {
|
app.register(registerAuthRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/auth',
|
prefix: "/api/v1/auth",
|
||||||
});
|
});
|
||||||
app.register(registerAdminProductRoutes, {
|
app.register(registerAdminProductRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminInventoryRoutes, {
|
app.register(registerAdminInventoryRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminLookupRoutes, {
|
app.register(registerAdminLookupRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminImageRoutes, {
|
app.register(registerAdminImageRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminListingRoutes, {
|
app.register(registerAdminListingRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminSaleRoutes, {
|
app.register(registerAdminSaleRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminAcquisitionRoutes, {
|
app.register(registerAdminAcquisitionRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminDashboardRoutes, {
|
app.register(registerAdminDashboardRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminPricingRoutes, {
|
app.register(registerAdminPricingRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerAdminProviderMappingRoutes, {
|
app.register(registerAdminProviderMappingRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
app.register(registerPublicSearchRoutes, {
|
app.register(registerPublicSearchRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicProductRoutes, {
|
app.register(registerPublicProductRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicListingRoutes, {
|
app.register(registerPublicListingRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicInventoryRoutes, {
|
app.register(registerPublicInventoryRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicImageRoutes, {
|
app.register(registerPublicImageRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicSellerRoutes, {
|
app.register(registerPublicSellerRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
app.register(registerPublicLookupRoutes, {
|
app.register(registerPublicLookupRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: "/api/v1",
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-72
@@ -1,85 +1,106 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url)) });
|
dotenv.config({
|
||||||
|
path: fileURLToPath(new URL("../../../../.env", import.meta.url)),
|
||||||
|
});
|
||||||
|
|
||||||
import { buildApp } from './build-app.js';
|
import { buildApp } from "./build-app.js";
|
||||||
import { createDatabase } from '../infrastructure/database/client.js';
|
import { createDatabase } from "../infrastructure/database/client.js";
|
||||||
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js';
|
import { resolveProjectImageStorageRoot } from "../infrastructure/images/local-image-storage.js";
|
||||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
import { createPasswordResetNotifier } from "../infrastructure/email/password-reset-notifier.js";
|
||||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
import { createRedisConnection } from "../infrastructure/redis/client.js";
|
||||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
import { createAuthenticationService } from "../modules/auth/services/authentication-service.js";
|
||||||
import { createMfaService } from '../modules/auth/services/mfa-service.js';
|
import { createMfaService } from "../modules/auth/services/mfa-service.js";
|
||||||
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js';
|
import { createSecretProtector } from "../modules/auth/domain/secret-protection.js";
|
||||||
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js';
|
import { createMfaMethodRepository } from "../modules/auth/repositories/mfa-method-repository.js";
|
||||||
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js';
|
import { createSecurityAuditRepository } from "../modules/auth/repositories/security-audit-repository.js";
|
||||||
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js';
|
import { createSessionRepository } from "../modules/auth/repositories/session-repository.js";
|
||||||
import { createUserRepository } from '../modules/auth/repositories/user-repository.js';
|
import { createUserRepository } from "../modules/auth/repositories/user-repository.js";
|
||||||
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js';
|
import { createPasswordResetRepository } from "../modules/auth/repositories/password-reset-repository.js";
|
||||||
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js';
|
import { createRedisLoginRateLimiter } from "../modules/auth/services/login-rate-limiter.js";
|
||||||
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js';
|
import { createPasswordRecoveryService } from "../modules/auth/services/password-recovery-service.js";
|
||||||
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
|
import { createInventoryItemRepository } from "../modules/inventory/repositories/inventory-item-repository.js";
|
||||||
import { createCatalogLookupRepository } from '../modules/inventory/repositories/catalog-lookup-repository.js';
|
import { createCatalogLookupRepository } from "../modules/inventory/repositories/catalog-lookup-repository.js";
|
||||||
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
|
import { createInventoryItemService } from "../modules/inventory/services/inventory-item-service.js";
|
||||||
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js';
|
import { createPublicInventoryService } from "../modules/inventory/services/public-inventory-service.js";
|
||||||
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js';
|
import { createPublicImageService } from "../modules/inventory/services/public-image-service.js";
|
||||||
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
|
import { createInventoryImageRepository } from "../modules/inventory/repositories/inventory-image-repository.js";
|
||||||
import { createImageDerivativeJobRepository } from '../modules/inventory/repositories/image-derivative-job-repository.js';
|
import { createImageDerivativeJobRepository } from "../modules/inventory/repositories/image-derivative-job-repository.js";
|
||||||
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
|
import { createInventoryImageService } from "../modules/inventory/services/inventory-image-service.js";
|
||||||
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js';
|
import { createLocalImageStorage } from "../infrastructure/images/local-image-storage.js";
|
||||||
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js';
|
import { createConditionRepository } from "../modules/inventory/repositories/condition-repository.js";
|
||||||
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js';
|
import { createListingRepository } from "../modules/listings/repositories/listing-repository.js";
|
||||||
import { createListingService } from '../modules/listings/services/listing-service.js';
|
import { createListingService } from "../modules/listings/services/listing-service.js";
|
||||||
import { createPublicListingService } from '../modules/listings/services/public-listing-service.js';
|
import { createPublicListingService } from "../modules/listings/services/public-listing-service.js";
|
||||||
import { createProductRepository } from '../modules/products/repositories/product-repository.js';
|
import { createProductRepository } from "../modules/products/repositories/product-repository.js";
|
||||||
import { createProductService } from '../modules/products/services/product-service.js';
|
import { createProductService } from "../modules/products/services/product-service.js";
|
||||||
import { createPublicProductService } from '../modules/products/services/public-product-service.js';
|
import { createPublicProductService } from "../modules/products/services/public-product-service.js";
|
||||||
import { createSearchService } from '../modules/search/services/search-service.js';
|
import { createSearchService } from "../modules/search/services/search-service.js";
|
||||||
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
import { createSellerRepository } from "../modules/sellers/repositories/seller-repository.js";
|
||||||
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
import { createPublicSellerService } from "../modules/sellers/services/public-seller-service.js";
|
||||||
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js';
|
import { createSaleRepository } from "../modules/sales/repositories/sale-repository.js";
|
||||||
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
import { createSaleService } from "../modules/sales/services/sale-service.js";
|
||||||
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js';
|
import { createAcquisitionRepository } from "../modules/acquisitions/repositories/acquisition-repository.js";
|
||||||
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js';
|
import { createAcquisitionService } from "../modules/acquisitions/services/acquisition-service.js";
|
||||||
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js';
|
import { createDashboardService } from "../modules/dashboard/services/dashboard-service.js";
|
||||||
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js';
|
import { createPricingRepository } from "../modules/pricing/repositories/pricing-repository.js";
|
||||||
import { createPricingService } from '../modules/pricing/services/pricing-service.js';
|
import { createPricingService } from "../modules/pricing/services/pricing-service.js";
|
||||||
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js';
|
import { createPricingRefreshJobRepository } from "../modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||||
import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js';
|
import { createProviderMappingRepository } from "../modules/pricing/repositories/provider-mapping-repository.js";
|
||||||
import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js';
|
import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
|
||||||
import { createPricingProviderRegistry } from '../modules/pricing/providers/pricing-provider.js';
|
import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
|
||||||
import { createFixturePricingProvider } from '../modules/pricing/providers/fixture-pricing-provider.js';
|
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
||||||
|
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
|
||||||
|
|
||||||
function parsePort(value) {
|
function parsePort(value) {
|
||||||
const port = Number.parseInt(value ?? '3000', 10);
|
const port = Number.parseInt(value ?? "3000", 10);
|
||||||
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
throw new Error('API_PORT must be an integer between 1 and 65535.');
|
throw new Error("API_PORT must be an integer between 1 and 65535.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
const imageStorageRoot = resolveProjectImageStorageRoot(
|
||||||
|
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const redis = await createRedisConnection();
|
const redis = await createRedisConnection();
|
||||||
const productRepository = createProductRepository(database.db);
|
const productRepository = createProductRepository(database.db);
|
||||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||||
const catalogLookupRepository = createCatalogLookupRepository(database.db);
|
const catalogLookupRepository = createCatalogLookupRepository(database.db);
|
||||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||||
const imageDerivativeJobRepository = createImageDerivativeJobRepository(database.db);
|
const imageDerivativeJobRepository = createImageDerivativeJobRepository(
|
||||||
|
database.db,
|
||||||
|
);
|
||||||
const listingRepository = createListingRepository(database.db);
|
const listingRepository = createListingRepository(database.db);
|
||||||
const sellerRepository = createSellerRepository(database.db);
|
const sellerRepository = createSellerRepository(database.db);
|
||||||
const conditionRepository = createConditionRepository(database.db);
|
const conditionRepository = createConditionRepository(database.db);
|
||||||
const saleRepository = createSaleRepository(database.db);
|
const saleRepository = createSaleRepository(database.db);
|
||||||
const acquisitionRepository = createAcquisitionRepository(database.db);
|
const acquisitionRepository = createAcquisitionRepository(database.db);
|
||||||
const pricingRepository = createPricingRepository(database.db);
|
const pricingRepository = createPricingRepository(database.db);
|
||||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||||
const providerMappingRepository = createProviderMappingRepository(database.db);
|
database.db,
|
||||||
const pricingProviders = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true'
|
);
|
||||||
? createPricingProviderRegistry([createFixturePricingProvider()])
|
const providerMappingRepository = createProviderMappingRepository(
|
||||||
: createPricingProviderRegistry();
|
database.db,
|
||||||
|
);
|
||||||
|
const providers = [];
|
||||||
|
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
||||||
|
providers.push(createFixturePricingProvider());
|
||||||
|
if (process.env.PRICING_HTTP_PROVIDER_ENDPOINT)
|
||||||
|
providers.push(
|
||||||
|
createHttpPricingProvider({
|
||||||
|
id: process.env.PRICING_HTTP_PROVIDER_ID ?? "http-provider",
|
||||||
|
endpoint: process.env.PRICING_HTTP_PROVIDER_ENDPOINT,
|
||||||
|
token: process.env.PRICING_HTTP_PROVIDER_TOKEN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const pricingProviders = createPricingProviderRegistry(providers);
|
||||||
const userRepository = createUserRepository(database.db);
|
const userRepository = createUserRepository(database.db);
|
||||||
const sessionRepository = createSessionRepository(database.db);
|
const sessionRepository = createSessionRepository(database.db);
|
||||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||||
@@ -105,11 +126,15 @@ async function startServer() {
|
|||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticationService,
|
authenticationService,
|
||||||
authenticateAdmin: (request) =>
|
authenticateAdmin: (request) =>
|
||||||
authenticationService.authenticate(request.cookies.pokemon_inventory_session),
|
authenticationService.authenticate(
|
||||||
|
request.cookies.pokemon_inventory_session,
|
||||||
|
),
|
||||||
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
||||||
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
||||||
isProduction: process.env.NODE_ENV === 'production',
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }),
|
loginRateLimiter: createRedisLoginRateLimiter({
|
||||||
|
redisClient: redis.client,
|
||||||
|
}),
|
||||||
mfaService,
|
mfaService,
|
||||||
passwordRecoveryService: createPasswordRecoveryService({
|
passwordRecoveryService: createPasswordRecoveryService({
|
||||||
passwordResetNotifier,
|
passwordResetNotifier,
|
||||||
@@ -118,7 +143,7 @@ async function startServer() {
|
|||||||
userRepository,
|
userRepository,
|
||||||
}),
|
}),
|
||||||
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
||||||
keyPrefix: 'pokemon-inventory:auth:password-reset:',
|
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
||||||
redisClient: redis.client,
|
redisClient: redis.client,
|
||||||
}),
|
}),
|
||||||
inventoryItemService: createInventoryItemService({
|
inventoryItemService: createInventoryItemService({
|
||||||
@@ -128,42 +153,65 @@ async function startServer() {
|
|||||||
}),
|
}),
|
||||||
catalogLookupRepository,
|
catalogLookupRepository,
|
||||||
publicImageService,
|
publicImageService,
|
||||||
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
publicInventoryService: createPublicInventoryService({
|
||||||
|
inventoryItemRepository,
|
||||||
|
publicImageService,
|
||||||
|
}),
|
||||||
inventoryImageService: createInventoryImageService({
|
inventoryImageService: createInventoryImageService({
|
||||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||||
imageDerivativeJobRepository,
|
imageDerivativeJobRepository,
|
||||||
inventoryImageRepository,
|
inventoryImageRepository,
|
||||||
}),
|
}),
|
||||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
listingService: createListingService({
|
||||||
|
listingRepository,
|
||||||
|
inventoryImageRepository,
|
||||||
|
}),
|
||||||
saleService: createSaleService({ saleRepository }),
|
saleService: createSaleService({ saleRepository }),
|
||||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
dashboardService: createDashboardService({
|
dashboardService: createDashboardService({
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }),
|
inventoryItemService: createInventoryItemService({
|
||||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
conditionRepository,
|
||||||
|
inventoryItemRepository,
|
||||||
|
productRepository,
|
||||||
|
}),
|
||||||
|
listingService: createListingService({
|
||||||
|
listingRepository,
|
||||||
|
inventoryImageRepository,
|
||||||
|
}),
|
||||||
saleService: createSaleService({ saleRepository }),
|
saleService: createSaleService({ saleRepository }),
|
||||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
pricingRepository,
|
pricingRepository,
|
||||||
pricingRefreshJobRepository,
|
pricingRefreshJobRepository,
|
||||||
}),
|
}),
|
||||||
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }),
|
pricingService: createPricingService({
|
||||||
providerMappingService: createProviderMappingService({ providerMappingRepository }),
|
pricingRepository,
|
||||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
pricingRefreshJobRepository,
|
||||||
|
providerMappingRepository,
|
||||||
|
pricingProviderRegistry: pricingProviders,
|
||||||
|
}),
|
||||||
|
providerMappingService: createProviderMappingService({
|
||||||
|
providerMappingRepository,
|
||||||
|
}),
|
||||||
|
publicListingService: createPublicListingService({
|
||||||
|
listingRepository,
|
||||||
|
publicImageService,
|
||||||
|
}),
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
publicProductService: createPublicProductService({ productRepository }),
|
publicProductService: createPublicProductService({ productRepository }),
|
||||||
searchService: createSearchService({ listingRepository }),
|
searchService: createSearchService({ listingRepository }),
|
||||||
publicSellerService: createPublicSellerService({ sellerRepository }),
|
publicSellerService: createPublicSellerService({ sellerRepository }),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook("onClose", async () => {
|
||||||
await database.close();
|
await database.close();
|
||||||
await redis.close();
|
await redis.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) });
|
await app.listen({ host: "0.0.0.0", port: parsePort(process.env.API_PORT) });
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer().catch((error) => {
|
startServer().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,100 +10,100 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const userRole = pgEnum('user_role', ['ADMIN']);
|
export const userRole = pgEnum("user_role", ["ADMIN"]);
|
||||||
export const inventoryStatus = pgEnum('inventory_status', [
|
export const inventoryStatus = pgEnum("inventory_status", [
|
||||||
'ACTIVE',
|
"ACTIVE",
|
||||||
'SOLD',
|
"SOLD",
|
||||||
'ARCHIVED',
|
"ARCHIVED",
|
||||||
]);
|
]);
|
||||||
export const listingStatus = pgEnum('listing_status', [
|
export const listingStatus = pgEnum("listing_status", [
|
||||||
'DRAFT',
|
"DRAFT",
|
||||||
'AVAILABLE',
|
"AVAILABLE",
|
||||||
'PENDING',
|
"PENDING",
|
||||||
'CLOSED',
|
"CLOSED",
|
||||||
'ARCHIVED',
|
"ARCHIVED",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const timestamps = {
|
const timestamps = {
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
'users',
|
"users",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
email: text('email').notNull(),
|
email: text("email").notNull(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text("password_hash").notNull(),
|
||||||
role: userRole('role').default('ADMIN').notNull(),
|
role: userRole("role").default("ADMIN").notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex('users_email_unique').on(table.email)],
|
(table) => [uniqueIndex("users_email_unique").on(table.email)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const sellers = pgTable(
|
export const sellers = pgTable(
|
||||||
'sellers',
|
"sellers",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id')
|
userId: uuid("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
displayName: text('display_name').notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)],
|
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable(
|
||||||
'sessions',
|
"sessions",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id')
|
userId: uuid("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
sessionTokenHash: text('session_token_hash').notNull(),
|
sessionTokenHash: text("session_token_hash").notNull(),
|
||||||
csrfTokenHash: text('csrf_token_hash').notNull(),
|
csrfTokenHash: text("csrf_token_hash").notNull(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true })
|
lastActivityAt: timestamp("last_activity_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ipAddress: text('ip_address'),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text("user_agent"),
|
||||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash),
|
uniqueIndex("sessions_token_hash_unique").on(table.sessionTokenHash),
|
||||||
index('sessions_user_id_index').on(table.userId),
|
index("sessions_user_id_index").on(table.userId),
|
||||||
index('sessions_expires_at_index').on(table.expiresAt),
|
index("sessions_expires_at_index").on(table.expiresAt),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const securityAuditEvents = pgTable(
|
export const securityAuditEvents = pgTable(
|
||||||
'security_audit_events',
|
"security_audit_events",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id').references(() => users.id),
|
userId: uuid("user_id").references(() => users.id),
|
||||||
event: text('event').notNull(),
|
event: text("event").notNull(),
|
||||||
ipAddress: text('ip_address'),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text('user_agent'),
|
userAgent: text("user_agent"),
|
||||||
metadata: jsonb('metadata').default({}).notNull(),
|
metadata: jsonb("metadata").default({}).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('security_audit_events_user_id_index').on(table.userId),
|
index("security_audit_events_user_id_index").on(table.userId),
|
||||||
index('security_audit_events_event_created_at_index').on(
|
index("security_audit_events_event_created_at_index").on(
|
||||||
table.event,
|
table.event,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
@@ -111,89 +111,89 @@ export const securityAuditEvents = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const passwordResetTokens = pgTable(
|
export const passwordResetTokens = pgTable(
|
||||||
'password_reset_tokens',
|
"password_reset_tokens",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id')
|
userId: uuid("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
tokenHash: text('token_hash').notNull(),
|
tokenHash: text("token_hash").notNull(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash),
|
uniqueIndex("password_reset_tokens_token_hash_unique").on(table.tokenHash),
|
||||||
index('password_reset_tokens_user_id_index').on(table.userId),
|
index("password_reset_tokens_user_id_index").on(table.userId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mfaMethods = pgTable(
|
export const mfaMethods = pgTable(
|
||||||
'mfa_methods',
|
"mfa_methods",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
userId: uuid('user_id')
|
userId: uuid("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
type: text('type').notNull(),
|
type: text("type").notNull(),
|
||||||
secretCiphertext: text('secret_ciphertext').notNull(),
|
secretCiphertext: text("secret_ciphertext").notNull(),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
isEnabled: boolean('is_enabled').default(false).notNull(),
|
isEnabled: boolean("is_enabled").default(false).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||||
},
|
},
|
||||||
(table) => [index('mfa_methods_user_id_index').on(table.userId)],
|
(table) => [index("mfa_methods_user_id_index").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const categories = pgTable(
|
export const categories = pgTable(
|
||||||
'categories',
|
"categories",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
slug: text('slug').notNull(),
|
slug: text("slug").notNull(),
|
||||||
active: boolean('active').default(true).notNull(),
|
active: boolean("active").default(true).notNull(),
|
||||||
sort: integer('sort').default(0).notNull(),
|
sort: integer("sort").default(0).notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('categories_name_unique').on(table.name),
|
uniqueIndex("categories_name_unique").on(table.name),
|
||||||
uniqueIndex('categories_slug_unique').on(table.slug),
|
uniqueIndex("categories_slug_unique").on(table.slug),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const conditionProfiles = pgTable('condition_profiles', {
|
export const conditionProfiles = pgTable("condition_profiles", {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
categoryId: uuid('category_id')
|
categoryId: uuid("category_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => categories.id),
|
.references(() => categories.id),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
active: boolean('active').default(true).notNull(),
|
active: boolean("active").default(true).notNull(),
|
||||||
sort: integer('sort').default(0).notNull(),
|
sort: integer("sort").default(0).notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const conditions = pgTable(
|
export const conditions = pgTable(
|
||||||
'conditions',
|
"conditions",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
conditionProfileId: uuid('condition_profile_id')
|
conditionProfileId: uuid("condition_profile_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => conditionProfiles.id),
|
.references(() => conditionProfiles.id),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
code: text('code').notNull(),
|
code: text("code").notNull(),
|
||||||
qualityRank: integer('quality_rank').notNull(),
|
qualityRank: integer("quality_rank").notNull(),
|
||||||
description: text('description'),
|
description: text("description"),
|
||||||
active: boolean('active').default(true).notNull(),
|
active: boolean("active").default(true).notNull(),
|
||||||
sort: integer('sort').default(0).notNull(),
|
sort: integer("sort").default(0).notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('conditions_profile_code_unique').on(
|
uniqueIndex("conditions_profile_code_unique").on(
|
||||||
table.conditionProfileId,
|
table.conditionProfileId,
|
||||||
table.code,
|
table.code,
|
||||||
),
|
),
|
||||||
@@ -201,229 +201,290 @@ export const conditions = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const products = pgTable(
|
export const products = pgTable(
|
||||||
'products',
|
"products",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
sellerId: uuid('seller_id')
|
sellerId: uuid("seller_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sellers.id),
|
.references(() => sellers.id),
|
||||||
categoryId: uuid('category_id')
|
categoryId: uuid("category_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => categories.id),
|
.references(() => categories.id),
|
||||||
name: text('name').notNull(),
|
name: text("name").notNull(),
|
||||||
slug: text('slug').notNull(),
|
slug: text("slug").notNull(),
|
||||||
description: text('description'),
|
description: text("description"),
|
||||||
brand: text('brand'),
|
brand: text("brand"),
|
||||||
setName: text('set_name'),
|
setName: text("set_name"),
|
||||||
cardNumber: text('card_number'),
|
cardNumber: text("card_number"),
|
||||||
eventName: text('event_name'),
|
eventName: text("event_name"),
|
||||||
releaseYear: integer('release_year'),
|
releaseYear: integer("release_year"),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug),
|
uniqueIndex("products_seller_slug_unique").on(table.sellerId, table.slug),
|
||||||
check(
|
check(
|
||||||
'products_release_year_valid',
|
"products_release_year_valid",
|
||||||
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const inventoryItems = pgTable('inventory_items', {
|
export const inventoryItems = pgTable("inventory_items", {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
productId: uuid('product_id')
|
productId: uuid("product_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => products.id),
|
.references(() => products.id),
|
||||||
conditionId: uuid('condition_id')
|
conditionId: uuid("condition_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => conditions.id),
|
.references(() => conditions.id),
|
||||||
status: inventoryStatus('status').default('ACTIVE').notNull(),
|
status: inventoryStatus("status").default("ACTIVE").notNull(),
|
||||||
notes: text('notes'),
|
notes: text("notes"),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const acquisitions = pgTable('acquisitions', {
|
export const acquisitions = pgTable("acquisitions", {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
inventoryItemId: uuid('inventory_item_id')
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => inventoryItems.id),
|
.references(() => inventoryItems.id),
|
||||||
method: text('method').notNull(),
|
method: text("method").notNull(),
|
||||||
costMinor: integer('cost_minor').notNull(),
|
costMinor: integer("cost_minor").notNull(),
|
||||||
currency: text('currency').notNull(),
|
currency: text("currency").notNull(),
|
||||||
date: text('date'),
|
date: text("date"),
|
||||||
source: text('source'),
|
source: text("source"),
|
||||||
location: text('location'),
|
location: text("location"),
|
||||||
shippingMinor: integer('shipping_minor').default(0).notNull(),
|
shippingMinor: integer("shipping_minor").default(0).notNull(),
|
||||||
taxMinor: integer('tax_minor').default(0).notNull(),
|
taxMinor: integer("tax_minor").default(0).notNull(),
|
||||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||||
estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(),
|
estimatedTradeValueMinor: integer("estimated_trade_value_minor")
|
||||||
notes: text('notes'),
|
.default(0)
|
||||||
|
.notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pricingObservations = pgTable('pricing_observations', {
|
export const pricingObservations = pgTable(
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
"pricing_observations",
|
||||||
productId: uuid('product_id').notNull().references(() => products.id),
|
|
||||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
|
||||||
providerId: text('provider_id').notNull(),
|
|
||||||
priceMinor: integer('price_minor').notNull(),
|
|
||||||
currency: text('currency').notNull(),
|
|
||||||
sourceIdentifier: text('source_identifier').notNull(),
|
|
||||||
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
|
|
||||||
sourceUrl: text('source_url'),
|
|
||||||
qualityScore: integer('quality_score'),
|
|
||||||
isOutlier: boolean('is_outlier').default(false).notNull(),
|
|
||||||
outlierReason: text('outlier_reason'),
|
|
||||||
observedAt: timestamp('observed_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
...timestamps,
|
|
||||||
}, (table) => [index('pricing_observations_inventory_item_id_index').on(table.inventoryItemId)]);
|
|
||||||
|
|
||||||
export const marketValuations = pgTable('market_valuations', {
|
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
|
||||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
|
||||||
valueMinor: integer('value_minor').notNull(),
|
|
||||||
currency: text('currency').notNull(),
|
|
||||||
strategy: text('strategy').notNull(),
|
|
||||||
confidence: text('confidence').notNull(),
|
|
||||||
sourceCount: integer('source_count').notNull(),
|
|
||||||
calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const marketValueOverrides = pgTable('market_value_overrides', {
|
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
|
||||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
|
||||||
valueMinor: integer('value_minor').notNull(),
|
|
||||||
currency: text('currency').notNull(),
|
|
||||||
reason: text('reason').notNull(),
|
|
||||||
createdBy: uuid('created_by').notNull().references(() => users.id),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pricingProviderMappings = pgTable('pricing_provider_mappings', {
|
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
|
||||||
productId: uuid('product_id').notNull().references(() => products.id),
|
|
||||||
providerId: text('provider_id').notNull(),
|
|
||||||
mappingType: text('mapping_type').notNull(),
|
|
||||||
externalIdentifier: text('external_identifier'),
|
|
||||||
searchQuery: text('search_query'),
|
|
||||||
sourceUrl: text('source_url'),
|
|
||||||
status: text('status').default('ACTIVE').notNull(),
|
|
||||||
metadata: jsonb('metadata').default({}).notNull(),
|
|
||||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
|
||||||
...timestamps,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', {
|
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
|
||||||
sellerId: uuid('seller_id').notNull().references(() => sellers.id),
|
|
||||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
|
||||||
productId: uuid('product_id').notNull().references(() => products.id),
|
|
||||||
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
|
|
||||||
providerId: text('provider_id').notNull(),
|
|
||||||
status: text('status').default('PENDING').notNull(),
|
|
||||||
attemptCount: integer('attempt_count').default(0).notNull(),
|
|
||||||
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }),
|
|
||||||
observations: jsonb('observations').default([]).notNull(),
|
|
||||||
valuation: jsonb('valuation'),
|
|
||||||
error: text('error'),
|
|
||||||
...timestamps,
|
|
||||||
}, (table) => [index('pricing_refresh_jobs_status_created_at_index').on(table.status, table.createdAt)]);
|
|
||||||
|
|
||||||
export const inventoryImages = pgTable(
|
|
||||||
'inventory_images',
|
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
inventoryItemId: uuid('inventory_item_id')
|
productId: uuid("product_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => inventoryItems.id),
|
.references(() => inventoryItems.id),
|
||||||
storageKey: text('storage_key').notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
originalFilename: text('original_filename').notNull(),
|
externalIdentifier: text("external_identifier"),
|
||||||
mimeType: text('mime_type').notNull(),
|
searchQuery: text("search_query"),
|
||||||
fileSize: integer('file_size').notNull(),
|
priceMinor: integer("price_minor").notNull(),
|
||||||
width: integer('width').notNull(),
|
currency: text("currency").notNull(),
|
||||||
height: integer('height').notNull(),
|
sourceIdentifier: text("source_identifier").notNull(),
|
||||||
altText: text('alt_text'),
|
conditionId: uuid("condition_id")
|
||||||
sortOrder: integer('sort_order').default(0).notNull(),
|
.notNull()
|
||||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
.references(() => conditions.id),
|
||||||
processingStatus: text('processing_status').default('PENDING').notNull(),
|
sourceUrl: text("source_url"),
|
||||||
|
qualityScore: integer("quality_score"),
|
||||||
|
isOutlier: boolean("is_outlier").default(false).notNull(),
|
||||||
|
outlierReason: text("outlier_reason"),
|
||||||
|
observedAt: timestamp("observed_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)],
|
(table) => [
|
||||||
|
index("pricing_observations_inventory_item_id_index").on(
|
||||||
|
table.inventoryItemId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const marketValuations = pgTable("market_valuations", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => inventoryItems.id),
|
||||||
|
valueMinor: integer("value_minor").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
strategy: text("strategy").notNull(),
|
||||||
|
confidence: text("confidence").notNull(),
|
||||||
|
sourceCount: integer("source_count").notNull(),
|
||||||
|
calculatedAt: timestamp("calculated_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const marketValueOverrides = pgTable("market_value_overrides", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => inventoryItems.id),
|
||||||
|
valueMinor: integer("value_minor").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
reason: text("reason").notNull(),
|
||||||
|
createdBy: uuid("created_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pricingProviderMappings = pgTable("pricing_provider_mappings", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
productId: uuid("product_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
mappingType: text("mapping_type").notNull(),
|
||||||
|
externalIdentifier: text("external_identifier"),
|
||||||
|
searchQuery: text("search_query"),
|
||||||
|
sourceUrl: text("source_url"),
|
||||||
|
status: text("status").default("ACTIVE").notNull(),
|
||||||
|
metadata: jsonb("metadata").default({}).notNull(),
|
||||||
|
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||||
|
...timestamps,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pricingRefreshJobs = pgTable(
|
||||||
|
"pricing_refresh_jobs",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
sellerId: uuid("seller_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sellers.id),
|
||||||
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => inventoryItems.id),
|
||||||
|
productId: uuid("product_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id),
|
||||||
|
conditionId: uuid("condition_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conditions.id),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
status: text("status").default("PENDING").notNull(),
|
||||||
|
attemptCount: integer("attempt_count").default(0).notNull(),
|
||||||
|
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
|
||||||
|
observations: jsonb("observations").default([]).notNull(),
|
||||||
|
valuation: jsonb("valuation"),
|
||||||
|
error: text("error"),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("pricing_refresh_jobs_status_created_at_index").on(
|
||||||
|
table.status,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const inventoryImages = pgTable(
|
||||||
|
"inventory_images",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => inventoryItems.id),
|
||||||
|
storageKey: text("storage_key").notNull(),
|
||||||
|
originalFilename: text("original_filename").notNull(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
fileSize: integer("file_size").notNull(),
|
||||||
|
width: integer("width").notNull(),
|
||||||
|
height: integer("height").notNull(),
|
||||||
|
altText: text("alt_text"),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
isPrimary: boolean("is_primary").default(false).notNull(),
|
||||||
|
processingStatus: text("processing_status").default("PENDING").notNull(),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("inventory_images_inventory_item_id_index").on(table.inventoryItemId),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const imageDerivativeJobs = pgTable(
|
export const imageDerivativeJobs = pgTable(
|
||||||
'image_derivative_jobs',
|
"image_derivative_jobs",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
inventoryImageId: uuid('inventory_image_id')
|
inventoryImageId: uuid("inventory_image_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => inventoryImages.id),
|
.references(() => inventoryImages.id),
|
||||||
sourceKey: text('source_key').notNull(),
|
sourceKey: text("source_key").notNull(),
|
||||||
status: text('status').default('PENDING').notNull(),
|
status: text("status").default("PENDING").notNull(),
|
||||||
derivatives: jsonb('derivatives').default([]).notNull(),
|
derivatives: jsonb("derivatives").default([]).notNull(),
|
||||||
error: text('error'),
|
error: text("error"),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
},
|
},
|
||||||
(table) => [index('image_derivative_jobs_image_id_index').on(table.inventoryImageId)],
|
(table) => [
|
||||||
|
index("image_derivative_jobs_image_id_index").on(table.inventoryImageId),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const listings = pgTable('listings', {
|
export const listings = pgTable(
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
"listings",
|
||||||
inventoryItemId: uuid('inventory_item_id')
|
{
|
||||||
.notNull()
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
.references(() => inventoryItems.id),
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
productName: text('product_name').notNull(),
|
.notNull()
|
||||||
categoryId: uuid('category_id').notNull(),
|
.references(() => inventoryItems.id),
|
||||||
conditionId: uuid('condition_id')
|
productName: text("product_name").notNull(),
|
||||||
.notNull()
|
categoryId: uuid("category_id").notNull(),
|
||||||
.references(() => conditions.id),
|
conditionId: uuid("condition_id")
|
||||||
askingPriceMinor: integer('asking_price_minor').notNull(),
|
.notNull()
|
||||||
status: listingStatus('status').default('DRAFT').notNull(),
|
.references(() => conditions.id),
|
||||||
notes: text('notes'),
|
askingPriceMinor: integer("asking_price_minor").notNull(),
|
||||||
...timestamps,
|
status: listingStatus("status").default("DRAFT").notNull(),
|
||||||
}, (table) => [
|
notes: text("notes"),
|
||||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
...timestamps,
|
||||||
index('listings_status_index').on(table.status),
|
},
|
||||||
]);
|
(table) => [
|
||||||
|
index("listings_inventory_item_id_index").on(table.inventoryItemId),
|
||||||
|
index("listings_status_index").on(table.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sales = pgTable('sales', {
|
export const sales = pgTable("sales", {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
sellerId: uuid('seller_id')
|
sellerId: uuid("seller_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sellers.id),
|
.references(() => sellers.id),
|
||||||
status: text('status').default('COMPLETED').notNull(),
|
status: text("status").default("COMPLETED").notNull(),
|
||||||
saleDate: text('sale_date').notNull(),
|
saleDate: text("sale_date").notNull(),
|
||||||
saleLocation: text('sale_location'),
|
saleLocation: text("sale_location"),
|
||||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||||
currency: text('currency').notNull(),
|
currency: text("currency").notNull(),
|
||||||
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(),
|
platformFeesMinor: integer("platform_fees_minor").default(0).notNull(),
|
||||||
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(),
|
paymentProcessingFeesMinor: integer("payment_processing_fees_minor")
|
||||||
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(),
|
.default(0)
|
||||||
taxesMinor: integer('taxes_minor').default(0).notNull(),
|
.notNull(),
|
||||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
shippingCostMinor: integer("shipping_cost_minor").default(0).notNull(),
|
||||||
buyerName: text('buyer_name'),
|
taxesMinor: integer("taxes_minor").default(0).notNull(),
|
||||||
buyerContact: text('buyer_contact'),
|
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||||
notes: text('notes'),
|
buyerName: text("buyer_name"),
|
||||||
voidReason: text('void_reason'),
|
buyerContact: text("buyer_contact"),
|
||||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
notes: text("notes"),
|
||||||
|
voidReason: text("void_reason"),
|
||||||
|
voidedAt: timestamp("voided_at", { withTimezone: true }),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saleItems = pgTable(
|
export const saleItems = pgTable(
|
||||||
'sale_items',
|
"sale_items",
|
||||||
{
|
{
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
saleId: uuid('sale_id')
|
saleId: uuid("sale_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sales.id),
|
.references(() => sales.id),
|
||||||
inventoryItemId: uuid('inventory_item_id')
|
inventoryItemId: uuid("inventory_item_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => inventoryItems.id),
|
.references(() => inventoryItems.id),
|
||||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||||
costBasisMinor: integer('cost_basis_minor').notNull(),
|
costBasisMinor: integer("cost_basis_minor").notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId),
|
uniqueIndex("sale_items_inventory_item_id_unique").on(
|
||||||
index('sale_items_sale_id_index').on(table.saleId),
|
table.inventoryItemId,
|
||||||
|
),
|
||||||
|
index("sale_items_sale_id_index").on(table.saleId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { and, desc, eq } from 'drizzle-orm';
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { acquisitions, inventoryItems, products } from '../../../infrastructure/database/schema.js';
|
import {
|
||||||
|
acquisitions,
|
||||||
|
inventoryItems,
|
||||||
|
products,
|
||||||
|
} from "../../../infrastructure/database/schema.js";
|
||||||
|
|
||||||
export function createAcquisitionRepository(db) {
|
export function createAcquisitionRepository(db) {
|
||||||
return {
|
return {
|
||||||
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
|
|||||||
.select({ id: inventoryItems.id })
|
.select({ id: inventoryItems.id })
|
||||||
.from(inventoryItems)
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inventoryItems.id, acquisition.inventoryItemId),
|
||||||
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||||
const [created] = await transaction.insert(acquisitions).values(acquisition).returning();
|
const [created] = await transaction
|
||||||
return { status: 'CREATED', acquisition: created };
|
.insert(acquisitions)
|
||||||
|
.values(acquisition)
|
||||||
|
.returning();
|
||||||
|
return { status: "CREATED", acquisition: created };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -22,11 +34,14 @@ export function createAcquisitionRepository(db) {
|
|||||||
return db
|
return db
|
||||||
.select({ acquisition: acquisitions })
|
.select({ acquisition: acquisitions })
|
||||||
.from(acquisitions)
|
.from(acquisitions)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, acquisitions.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(eq(products.sellerId, sellerId))
|
.where(eq(products.sellerId, sellerId))
|
||||||
.orderBy(desc(acquisitions.createdAt))
|
.orderBy(desc(acquisitions.createdAt))
|
||||||
.then((rows) => rows.map(({ acquisition }) => acquisition));
|
.then((rows) => rows.map(({ acquisition }) => acquisition));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,64 @@
|
|||||||
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition';
|
import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
|
||||||
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition';
|
import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
function validationError(reply, result) {
|
function validationError(reply, result) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_ERROR',
|
code: "VALIDATION_ERROR",
|
||||||
message: 'Request validation failed.',
|
message: "Request validation failed.",
|
||||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
details: result.error.issues.map(({ message, path }) => ({
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAdminAcquisitionRoutes(app, options) {
|
export async function registerAdminAcquisitionRoutes(app, options) {
|
||||||
const {
|
const {
|
||||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
authenticateAdmin = async () => {
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
sessionCookieName = 'pokemon_inventory_session',
|
sessionCookieName = "pokemon_inventory_session",
|
||||||
acquisitionService,
|
acquisitionService,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
app.addHook('preHandler', async (request) => {
|
app.addHook("preHandler", async (request) => {
|
||||||
const admin = await authenticateAdmin(request);
|
const admin = await authenticateAdmin(request);
|
||||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
if (!admin?.sellerId)
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
request.admin = admin;
|
request.admin = admin;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/acquisitions', async (request) => ({
|
app.get("/acquisitions", async (request) => ({
|
||||||
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto),
|
data: (
|
||||||
|
await acquisitionService.list({ sellerId: request.admin.sellerId })
|
||||||
|
).map(toAdminAcquisitionDto),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post('/acquisitions', async (request, reply) => {
|
app.post("/acquisitions", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
const result = createAcquisitionSchema.safeParse(request.body);
|
const result = createAcquisitionSchema.safeParse(request.body);
|
||||||
if (!result.success) return validationError(reply, result);
|
if (!result.success) return validationError(reply, result);
|
||||||
|
|
||||||
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId });
|
const acquisition = await acquisitionService.create({
|
||||||
|
...result.data,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
|
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js';
|
import {
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
createAcquisition,
|
||||||
|
calculateAcquisitionCostBasis,
|
||||||
|
} from "../domain/acquisition.js";
|
||||||
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
export function createAcquisitionService({ acquisitionRepository }) {
|
export function createAcquisitionService({ acquisitionRepository }) {
|
||||||
return {
|
return {
|
||||||
async create({ sellerId, ...input }) {
|
async create({ sellerId, ...input }) {
|
||||||
const acquisition = createAcquisition(input);
|
const acquisition = createAcquisition(input);
|
||||||
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId });
|
const result = await acquisitionRepository.createForSeller({
|
||||||
if (result.status === 'NOT_FOUND') {
|
acquisition,
|
||||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
sellerId,
|
||||||
|
});
|
||||||
|
if (result.status === "NOT_FOUND") {
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard';
|
import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
export async function registerAdminDashboardRoutes(app, options) {
|
export async function registerAdminDashboardRoutes(app, options) {
|
||||||
const {
|
const {
|
||||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
authenticateAdmin = async () => {
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
dashboardService,
|
dashboardService,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
app.addHook('preHandler', async (request) => {
|
app.addHook("preHandler", async (request) => {
|
||||||
const admin = await authenticateAdmin(request);
|
const admin = await authenticateAdmin(request);
|
||||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
if (!admin?.sellerId)
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
request.admin = admin;
|
request.admin = admin;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/dashboard', async (request) => ({
|
app.get("/dashboard", async (request) => ({
|
||||||
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
|
data: toAdminDashboardDto(
|
||||||
|
await dashboardService.get({ sellerId: request.admin.sellerId }),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) {
|
export function createDashboardService({
|
||||||
|
productService,
|
||||||
|
inventoryItemService,
|
||||||
|
listingService,
|
||||||
|
saleService,
|
||||||
|
acquisitionService,
|
||||||
|
pricingRepository,
|
||||||
|
pricingRefreshJobRepository,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
async get({ sellerId }) {
|
async get({ sellerId }) {
|
||||||
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([
|
const [
|
||||||
|
products,
|
||||||
|
inventory,
|
||||||
|
listings,
|
||||||
|
sales,
|
||||||
|
acquisitions,
|
||||||
|
valuations,
|
||||||
|
failedPricingJobs,
|
||||||
|
] = await Promise.all([
|
||||||
productService.listBySellerId(sellerId),
|
productService.listBySellerId(sellerId),
|
||||||
inventoryItemService.listBySellerId(sellerId),
|
inventoryItemService.listBySellerId(sellerId),
|
||||||
listingService.list({ sellerId }),
|
listingService.list({ sellerId }),
|
||||||
@@ -11,36 +27,51 @@ export function createDashboardService({ productService, inventoryItemService, l
|
|||||||
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
|
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
|
const activeListings = listings.filter(({ status }) =>
|
||||||
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
|
["AVAILABLE", "PENDING"].includes(status),
|
||||||
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation]));
|
);
|
||||||
|
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
|
||||||
|
const valuationByItem = new Map(
|
||||||
|
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
|
||||||
|
);
|
||||||
const staleThreshold = 7 * 24 * 60 * 60 * 1000;
|
const staleThreshold = 7 * 24 * 60 * 60 * 1000;
|
||||||
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
|
const total = (values, field) =>
|
||||||
|
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
counts: {
|
counts: {
|
||||||
products: products.length,
|
products: products.length,
|
||||||
inventory: inventory.length,
|
inventory: inventory.length,
|
||||||
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
|
activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
|
||||||
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
|
.length,
|
||||||
|
soldInventory: inventory.filter(({ status }) => status === "SOLD")
|
||||||
|
.length,
|
||||||
availableListings: activeListings.length,
|
availableListings: activeListings.length,
|
||||||
sales: completedSales.length,
|
sales: completedSales.length,
|
||||||
acquisitions: acquisitions.length,
|
acquisitions: acquisitions.length,
|
||||||
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
|
missingImages: listings.filter(({ imageCount }) => imageCount < 1)
|
||||||
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length,
|
.length,
|
||||||
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length,
|
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
|
||||||
|
.length,
|
||||||
|
stalePricing: valuations.filter(
|
||||||
|
({ calculatedAt }) =>
|
||||||
|
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
|
||||||
|
).length,
|
||||||
failedPricingJobs,
|
failedPricingJobs,
|
||||||
},
|
},
|
||||||
financials: {
|
financials: {
|
||||||
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
|
totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
|
||||||
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
|
totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
|
||||||
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
|
totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
|
||||||
totalProfitMinor: total(completedSales, 'profitMinor'),
|
totalProfitMinor: total(completedSales, "profitMinor"),
|
||||||
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'),
|
totalMarketValueMinor: total(
|
||||||
|
[...valuationByItem.values()],
|
||||||
|
"valueMinor",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
recentSales: completedSales.slice(0, 5),
|
recentSales: completedSales.slice(0, 5),
|
||||||
recentAcquisitions: acquisitions.slice(0, 5),
|
recentAcquisitions: acquisitions.slice(0, 5),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
export async function registerPublicLookupRoutes(app, options) {
|
export async function registerPublicLookupRoutes(app, options) {
|
||||||
const { catalogLookupRepository } = options;
|
const { catalogLookupRepository } = options;
|
||||||
|
|
||||||
app.get('/public/lookups/categories', async () => ({
|
app.get("/public/lookups/categories", async () => ({
|
||||||
data: await catalogLookupRepository.listCategories(),
|
data: await catalogLookupRepository.listCategories(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get('/public/lookups/conditions', async () => ({
|
app.get("/public/lookups/conditions", async () => ({
|
||||||
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })),
|
data: (await catalogLookupRepository.listConditions()).map(
|
||||||
|
({ id, name, code }) => ({ id, name, code }),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
conditions,
|
conditions,
|
||||||
@@ -7,12 +17,15 @@ import {
|
|||||||
marketValueOverrides,
|
marketValueOverrides,
|
||||||
marketValuations,
|
marketValuations,
|
||||||
products,
|
products,
|
||||||
} from '../../../infrastructure/database/schema.js';
|
} from "../../../infrastructure/database/schema.js";
|
||||||
|
|
||||||
export function createListingRepository(db) {
|
export function createListingRepository(db) {
|
||||||
return {
|
return {
|
||||||
async create(listing) {
|
async create(listing) {
|
||||||
const [createdListing] = await db.insert(listings).values(listing).returning();
|
const [createdListing] = await db
|
||||||
|
.insert(listings)
|
||||||
|
.values(listing)
|
||||||
|
.returning();
|
||||||
return createdListing;
|
return createdListing;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -28,18 +41,28 @@ export function createListingRepository(db) {
|
|||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
||||||
.from(listings)
|
.from(listings)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, listings.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
|
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
|
||||||
|
);
|
||||||
|
|
||||||
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
|
return result
|
||||||
|
? { ...result.listing, inventoryStatus: result.inventoryStatus }
|
||||||
|
: null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async listBySellerId(sellerId) {
|
async listBySellerId(sellerId) {
|
||||||
const results = await db
|
const results = await db
|
||||||
.select({ listing: listings })
|
.select({ listing: listings })
|
||||||
.from(listings)
|
.from(listings)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, listings.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(eq(products.sellerId, sellerId))
|
.where(eq(products.sellerId, sellerId))
|
||||||
.orderBy(desc(listings.createdAt));
|
.orderBy(desc(listings.createdAt));
|
||||||
@@ -49,7 +72,7 @@ export function createListingRepository(db) {
|
|||||||
|
|
||||||
async searchPublic(filters = {}) {
|
async searchPublic(filters = {}) {
|
||||||
const {
|
const {
|
||||||
text = '',
|
text = "",
|
||||||
category,
|
category,
|
||||||
conditionId,
|
conditionId,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
@@ -57,35 +80,40 @@ export function createListingRepository(db) {
|
|||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
includeSold = false,
|
includeSold = false,
|
||||||
sort = 'recent',
|
sort = "recent",
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const conditions = [
|
const conditions = [
|
||||||
inArray(
|
inArray(
|
||||||
listings.status,
|
listings.status,
|
||||||
includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'],
|
includeSold
|
||||||
|
? ["AVAILABLE", "PENDING", "CLOSED"]
|
||||||
|
: ["AVAILABLE", "PENDING"],
|
||||||
|
),
|
||||||
|
inArray(
|
||||||
|
inventoryItems.status,
|
||||||
|
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
|
||||||
),
|
),
|
||||||
inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (typeof category === 'string' && category.trim().length > 0) {
|
if (typeof category === "string" && category.trim().length > 0) {
|
||||||
conditions.push(eq(products.categoryId, category));
|
conditions.push(eq(products.categoryId, category));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof conditionId === 'string' && conditionId.trim().length > 0) {
|
if (typeof conditionId === "string" && conditionId.trim().length > 0) {
|
||||||
conditions.push(eq(inventoryItems.conditionId, conditionId));
|
conditions.push(eq(inventoryItems.conditionId, conditionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof minPriceMinor === 'number') {
|
if (typeof minPriceMinor === "number") {
|
||||||
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
|
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof maxPriceMinor === 'number') {
|
if (typeof maxPriceMinor === "number") {
|
||||||
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
|
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof text === 'string' && text.trim().length > 0) {
|
if (typeof text === "string" && text.trim().length > 0) {
|
||||||
const searchTerms = text.split(' ').filter(Boolean);
|
const searchTerms = text.split(" ").filter(Boolean);
|
||||||
|
|
||||||
if (searchTerms.length > 0) {
|
if (searchTerms.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -105,28 +133,35 @@ export function createListingRepository(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const sortOrder = sort === 'price_low'
|
const sortOrder =
|
||||||
? listings.askingPriceMinor
|
sort === "price_low"
|
||||||
: sort === 'price_high'
|
? listings.askingPriceMinor
|
||||||
? desc(listings.askingPriceMinor)
|
: sort === "price_high"
|
||||||
: desc(listings.createdAt);
|
? desc(listings.askingPriceMinor)
|
||||||
|
: desc(listings.createdAt);
|
||||||
|
|
||||||
const [countResult, results] = await Promise.all([
|
const [countResult, results] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(listings)
|
.from(listings)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, listings.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(...conditions)),
|
.where(and(...conditions)),
|
||||||
db
|
db
|
||||||
.select({ listing: listings })
|
.select({ listing: listings })
|
||||||
.from(listings)
|
.from(listings)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
.innerJoin(
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
inventoryItems,
|
||||||
.where(and(...conditions))
|
eq(inventoryItems.id, listings.inventoryItemId),
|
||||||
.orderBy(sortOrder, desc(listings.createdAt))
|
)
|
||||||
.limit(limit)
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.offset(offset),
|
.where(and(...conditions))
|
||||||
|
.orderBy(sortOrder, desc(listings.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -139,22 +174,40 @@ export function createListingRepository(db) {
|
|||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({ listing: listings, condition: conditions })
|
.select({ listing: listings, condition: conditions })
|
||||||
.from(listings)
|
.from(listings)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, listings.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
|
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
|
||||||
.where(and(
|
.where(
|
||||||
eq(listings.id, listingId),
|
and(
|
||||||
inArray(listings.status, ['AVAILABLE', 'PENDING']),
|
eq(listings.id, listingId),
|
||||||
eq(inventoryItems.status, 'ACTIVE'),
|
inArray(listings.status, ["AVAILABLE", "PENDING"]),
|
||||||
));
|
eq(inventoryItems.status, "ACTIVE"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
const [override] = await db.select().from(marketValueOverrides)
|
const [override] = await db
|
||||||
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId))
|
.select()
|
||||||
.orderBy(desc(marketValueOverrides.createdAt)).limit(1);
|
.from(marketValueOverrides)
|
||||||
const [valuation] = await db.select().from(marketValuations)
|
.where(
|
||||||
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId))
|
eq(
|
||||||
.orderBy(desc(marketValuations.calculatedAt)).limit(1);
|
marketValueOverrides.inventoryItemId,
|
||||||
|
result.listing.inventoryItemId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(marketValueOverrides.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
const [valuation] = await db
|
||||||
|
.select()
|
||||||
|
.from(marketValuations)
|
||||||
|
.where(
|
||||||
|
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
|
||||||
|
)
|
||||||
|
.orderBy(desc(marketValuations.calculatedAt))
|
||||||
|
.limit(1);
|
||||||
return {
|
return {
|
||||||
...result.listing,
|
...result.listing,
|
||||||
conditionName: result.condition.name,
|
conditionName: result.condition.name,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function requiredText(value, fieldName) {
|
function requiredText(value, fieldName) {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
throw new Error(`${fieldName} is required.`);
|
throw new Error(`${fieldName} is required.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,28 +19,30 @@ export function createPricingObservation({
|
|||||||
inventoryItemId,
|
inventoryItemId,
|
||||||
providerId,
|
providerId,
|
||||||
priceMinor,
|
priceMinor,
|
||||||
currency = 'USD',
|
currency = "USD",
|
||||||
sourceIdentifier,
|
sourceIdentifier,
|
||||||
conditionId,
|
conditionId,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
qualityScore,
|
qualityScore,
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
productId: requiredText(productId, 'Product ID'),
|
productId: requiredText(productId, "Product ID"),
|
||||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||||
providerId: requiredText(providerId, 'Provider ID'),
|
providerId: requiredText(providerId, "Provider ID"),
|
||||||
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
|
priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
|
||||||
currency: requiredText(currency, 'Currency'),
|
currency: requiredText(currency, "Currency"),
|
||||||
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
|
sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
|
||||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
conditionId: requiredText(conditionId, "Condition ID"),
|
||||||
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
|
...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
|
||||||
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
|
? { sourceUrl: sourceUrl.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof qualityScore === "number" ? { qualityScore } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateMedianPriceMinor(prices) {
|
export function calculateMedianPriceMinor(prices) {
|
||||||
if (!Array.isArray(prices) || prices.length === 0) {
|
if (!Array.isArray(prices) || prices.length === 0) {
|
||||||
throw new Error('At least one price is required.');
|
throw new Error("At least one price is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...prices].sort((left, right) => left - right);
|
const sorted = [...prices].sort((left, right) => left - right);
|
||||||
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
|
|||||||
if (!Array.isArray(prices) || prices.length < 4) return new Set();
|
if (!Array.isArray(prices) || prices.length < 4) return new Set();
|
||||||
const sorted = [...prices].sort((left, right) => left - right);
|
const sorted = [...prices].sort((left, right) => left - right);
|
||||||
const middle = Math.floor(sorted.length / 2);
|
const middle = Math.floor(sorted.length / 2);
|
||||||
const median = sorted.length % 2
|
const median =
|
||||||
? sorted[middle]
|
sorted.length % 2
|
||||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
? sorted[middle]
|
||||||
|
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||||
const lowerFence = median / 3;
|
const lowerFence = median / 3;
|
||||||
const upperFence = median * 3;
|
const upperFence = median * 3;
|
||||||
return new Set(prices.filter((price) => price < lowerFence || price > upperFence));
|
return new Set(
|
||||||
|
prices.filter((price) => price < lowerFence || price > upperFence),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { createPricingProvider } from './pricing-provider.js';
|
import { createPricingProvider } from "./pricing-provider.js";
|
||||||
|
|
||||||
export function createFixturePricingProvider() {
|
export function createFixturePricingProvider() {
|
||||||
return createPricingProvider({
|
return createPricingProvider({
|
||||||
id: 'fixture-provider',
|
id: "fixture-provider",
|
||||||
async observe({ productId, inventoryItemId, conditionId }) {
|
async observe({ productId, inventoryItemId, conditionId }) {
|
||||||
return [{
|
return [
|
||||||
productId,
|
{
|
||||||
inventoryItemId,
|
productId,
|
||||||
conditionId,
|
inventoryItemId,
|
||||||
priceMinor: 2500,
|
conditionId,
|
||||||
currency: 'USD',
|
priceMinor: 2500,
|
||||||
sourceIdentifier: `fixture-${inventoryItemId}`,
|
currency: "USD",
|
||||||
qualityScore: 50,
|
sourceIdentifier: `fixture-${inventoryItemId}`,
|
||||||
}];
|
qualityScore: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export function createPricingProvider({ id, observe }) {
|
export function createPricingProvider({ id, observe }) {
|
||||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
if (typeof id !== "string" || id.trim().length === 0) {
|
||||||
throw new Error('Pricing provider id is required.');
|
throw new Error("Pricing provider id is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof observe !== 'function') {
|
if (typeof observe !== "function") {
|
||||||
throw new TypeError('Pricing provider observe function is required.');
|
throw new TypeError("Pricing provider observe function is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
|
|||||||
async observe(input) {
|
async observe(input) {
|
||||||
const observations = await observe(input);
|
const observations = await observe(input);
|
||||||
if (!Array.isArray(observations)) {
|
if (!Array.isArray(observations)) {
|
||||||
throw new Error(`Pricing provider ${id} must return an observation array.`);
|
throw new Error(
|
||||||
|
`Pricing provider ${id} must return an observation array.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return observations;
|
return observations;
|
||||||
},
|
},
|
||||||
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPricingProviderRegistry(providers = []) {
|
export function createPricingProviderRegistry(providers = []) {
|
||||||
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
|
const providerMap = new Map(
|
||||||
|
providers.map((provider) => [provider.id, provider]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get(providerId) {
|
get(providerId) {
|
||||||
|
|||||||
@@ -1,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) {
|
export function createPricingRefreshJobRepository(db) {
|
||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
return {
|
return {
|
||||||
async create(job) {
|
async create(job) {
|
||||||
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
|
const [created] = await db
|
||||||
|
.insert(pricingRefreshJobs)
|
||||||
|
.values(job)
|
||||||
|
.returning();
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
async claimNext() {
|
async claimNext() {
|
||||||
return db.transaction(async (transaction) => {
|
return db.transaction(async (transaction) => {
|
||||||
const [job] = await transaction.select().from(pricingRefreshJobs)
|
const [job] = await transaction
|
||||||
.where(and(
|
.select()
|
||||||
eq(pricingRefreshJobs.status, 'PENDING'),
|
.from(pricingRefreshJobs)
|
||||||
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
|
.where(
|
||||||
))
|
and(
|
||||||
|
eq(pricingRefreshJobs.status, "PENDING"),
|
||||||
|
or(
|
||||||
|
isNull(pricingRefreshJobs.nextAttemptAt),
|
||||||
|
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(asc(pricingRefreshJobs.createdAt))
|
.orderBy(asc(pricingRefreshJobs.createdAt))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.for('update', { skipLocked: true });
|
.for("update", { skipLocked: true });
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
const [claimed] = await transaction.update(pricingRefreshJobs)
|
const [claimed] = await transaction
|
||||||
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
|
.update(pricingRefreshJobs)
|
||||||
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
|
.set({
|
||||||
|
status: "PROCESSING",
|
||||||
|
attemptCount: pricingRefreshJobs.attemptCount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
error: null,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, job.id),
|
||||||
|
eq(pricingRefreshJobs.status, "PENDING"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return claimed ?? null;
|
return claimed ?? null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async findByIdForSeller(id, sellerId) {
|
async findByIdForSeller(id, sellerId) {
|
||||||
const [job] = await db.select().from(pricingRefreshJobs)
|
const [job] = await db
|
||||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId)));
|
.select()
|
||||||
|
.from(pricingRefreshJobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, id),
|
||||||
|
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
return job ?? null;
|
return job ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async countFailedForSeller(sellerId) {
|
async countFailedForSeller(sellerId) {
|
||||||
const rows = await db.select({ id: pricingRefreshJobs.id })
|
const rows = await db
|
||||||
|
.select({ id: pricingRefreshJobs.id })
|
||||||
.from(pricingRefreshJobs)
|
.from(pricingRefreshJobs)
|
||||||
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||||
|
eq(pricingRefreshJobs.status, "FAILED"),
|
||||||
|
),
|
||||||
|
);
|
||||||
return rows.length;
|
return rows.length;
|
||||||
},
|
},
|
||||||
|
|
||||||
async listForSeller(sellerId) {
|
async listForSeller(sellerId) {
|
||||||
return db.select().from(pricingRefreshJobs)
|
return db
|
||||||
|
.select()
|
||||||
|
.from(pricingRefreshJobs)
|
||||||
.where(eq(pricingRefreshJobs.sellerId, sellerId))
|
.where(eq(pricingRefreshJobs.sellerId, sellerId))
|
||||||
.orderBy(desc(pricingRefreshJobs.createdAt));
|
.orderBy(desc(pricingRefreshJobs.createdAt));
|
||||||
},
|
},
|
||||||
|
|
||||||
async retryForSeller(id, sellerId) {
|
async retryForSeller(id, sellerId) {
|
||||||
const [job] = await db.update(pricingRefreshJobs)
|
const [job] = await db
|
||||||
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() })
|
.update(pricingRefreshJobs)
|
||||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
|
.set({
|
||||||
|
status: "PENDING",
|
||||||
|
error: null,
|
||||||
|
observations: [],
|
||||||
|
valuation: null,
|
||||||
|
nextAttemptAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, id),
|
||||||
|
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||||
|
eq(pricingRefreshJobs.status, "FAILED"),
|
||||||
|
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return job ?? null;
|
return job ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async complete(id, observations, valuation) {
|
async complete(id, observations, valuation) {
|
||||||
const [completed] = await db.update(pricingRefreshJobs)
|
const [completed] = await db
|
||||||
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
|
.update(pricingRefreshJobs)
|
||||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
|
.set({
|
||||||
|
status: "COMPLETED",
|
||||||
|
observations,
|
||||||
|
valuation,
|
||||||
|
error: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, id),
|
||||||
|
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return completed ?? null;
|
return completed ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async fail(id, message) {
|
async fail(id, message) {
|
||||||
return db.transaction(async (transaction) => {
|
return db.transaction(async (transaction) => {
|
||||||
const [current] = await transaction.select().from(pricingRefreshJobs)
|
const [current] = await transaction
|
||||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
|
.select()
|
||||||
|
.from(pricingRefreshJobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, id),
|
||||||
|
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
const attemptCount = current.attemptCount + 1;
|
const attemptCount = current.attemptCount + 1;
|
||||||
const retryable = attemptCount < maxAttempts;
|
const retryable = attemptCount < maxAttempts;
|
||||||
const [updated] = await transaction.update(pricingRefreshJobs)
|
const [updated] = await transaction
|
||||||
|
.update(pricingRefreshJobs)
|
||||||
.set({
|
.set({
|
||||||
status: retryable ? 'PENDING' : 'FAILED',
|
status: retryable ? "PENDING" : "FAILED",
|
||||||
error: message,
|
error: message,
|
||||||
attemptCount,
|
attemptCount,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null,
|
nextAttemptAt: retryable
|
||||||
|
? new Date(Date.now() + 2 ** attemptCount * 1000)
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.id, id),
|
||||||
|
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async purgeCompletedBefore(sellerId, cutoff) {
|
async purgeCompletedBefore(sellerId, cutoff) {
|
||||||
const deleted = await db.delete(pricingRefreshJobs)
|
const deleted = await db
|
||||||
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
|
.delete(pricingRefreshJobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||||
|
eq(pricingRefreshJobs.status, "COMPLETED"),
|
||||||
|
lte(pricingRefreshJobs.updatedAt, cutoff),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning({ id: pricingRefreshJobs.id });
|
.returning({ id: pricingRefreshJobs.id });
|
||||||
return deleted.length;
|
return deleted.length;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js';
|
import {
|
||||||
import { identifyOutlierPrices } from '../domain/pricing-observation.js';
|
inventoryItems,
|
||||||
|
marketValueOverrides,
|
||||||
|
marketValuations,
|
||||||
|
pricingObservations,
|
||||||
|
products,
|
||||||
|
securityAuditEvents,
|
||||||
|
} from "../../../infrastructure/database/schema.js";
|
||||||
|
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
|
||||||
|
|
||||||
export function createPricingRepository(db) {
|
export function createPricingRepository(db) {
|
||||||
return {
|
return {
|
||||||
@@ -10,77 +17,165 @@ export function createPricingRepository(db) {
|
|||||||
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
|
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
|
||||||
.from(inventoryItems)
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' };
|
and(
|
||||||
const [created] = await db.insert(pricingObservations).values(observation).returning();
|
eq(inventoryItems.id, observation.inventoryItemId),
|
||||||
return { status: 'CREATED', observation: created };
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!ownedItem || ownedItem.productId !== observation.productId)
|
||||||
|
return { status: "NOT_FOUND" };
|
||||||
|
const [created] = await db
|
||||||
|
.insert(pricingObservations)
|
||||||
|
.values(observation)
|
||||||
|
.returning();
|
||||||
|
return { status: "CREATED", observation: created };
|
||||||
},
|
},
|
||||||
|
|
||||||
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) {
|
async calculateForSeller({
|
||||||
|
inventoryItemId,
|
||||||
|
sellerId,
|
||||||
|
strategy = "MEDIAN",
|
||||||
|
}) {
|
||||||
const [ownedItem] = await db
|
const [ownedItem] = await db
|
||||||
.select({ id: inventoryItems.id })
|
.select({ id: inventoryItems.id })
|
||||||
.from(inventoryItems)
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
and(
|
||||||
const observations = await db.select().from(pricingObservations)
|
eq(inventoryItems.id, inventoryItemId),
|
||||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false)))
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||||
|
const observations = await db
|
||||||
|
.select()
|
||||||
|
.from(pricingObservations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingObservations.inventoryItemId, inventoryItemId),
|
||||||
|
eq(pricingObservations.isOutlier, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(pricingObservations.observedAt));
|
.orderBy(desc(pricingObservations.observedAt));
|
||||||
if (!observations.length) return { status: 'NO_DATA' };
|
if (!observations.length) return { status: "NO_DATA" };
|
||||||
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right);
|
const prices = observations
|
||||||
|
.map(({ priceMinor }) => priceMinor)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
const middle = Math.floor(prices.length / 2);
|
const middle = Math.floor(prices.length / 2);
|
||||||
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2);
|
const valueMinor =
|
||||||
const [valuation] = await db.insert(marketValuations).values({
|
prices.length % 2
|
||||||
inventoryItemId,
|
? prices[middle]
|
||||||
valueMinor,
|
: Math.round((prices[middle - 1] + prices[middle]) / 2);
|
||||||
currency: 'USD',
|
const [valuation] = await db
|
||||||
strategy,
|
.insert(marketValuations)
|
||||||
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM',
|
.values({
|
||||||
sourceCount: observations.length,
|
inventoryItemId,
|
||||||
}).returning();
|
valueMinor,
|
||||||
return { status: 'CALCULATED', valuation, observations };
|
currency: "USD",
|
||||||
|
strategy,
|
||||||
|
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
|
||||||
|
sourceCount: observations.length,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return { status: "CALCULATED", valuation, observations };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listLatestValuationsForSeller(sellerId) {
|
async listLatestValuationsForSeller(sellerId) {
|
||||||
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id })
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
valuation: marketValuations,
|
||||||
|
inventoryItemId: inventoryItems.id,
|
||||||
|
})
|
||||||
.from(marketValuations)
|
.from(marketValuations)
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, marketValuations.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(eq(products.sellerId, sellerId))
|
.where(eq(products.sellerId, sellerId))
|
||||||
.orderBy(desc(marketValuations.calculatedAt));
|
.orderBy(desc(marketValuations.calculatedAt));
|
||||||
const latest = new Map();
|
const latest = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation);
|
if (!latest.has(row.inventoryItemId))
|
||||||
|
latest.set(row.inventoryItemId, row.valuation);
|
||||||
}
|
}
|
||||||
return [...latest.values()];
|
return [...latest.values()];
|
||||||
},
|
},
|
||||||
|
|
||||||
async markOutliersForSeller({ inventoryItemId, sellerId }) {
|
async markOutliersForSeller({ inventoryItemId, sellerId }) {
|
||||||
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems)
|
const [ownedItem] = await db
|
||||||
|
.select({ id: inventoryItems.id })
|
||||||
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
and(
|
||||||
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
eq(inventoryItems.id, inventoryItemId),
|
||||||
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||||
|
const observations = await db
|
||||||
|
.select()
|
||||||
|
.from(pricingObservations)
|
||||||
|
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||||
const prices = observations.map(({ priceMinor }) => priceMinor);
|
const prices = observations.map(({ priceMinor }) => priceMinor);
|
||||||
const outlierPrices = identifyOutlierPrices(prices);
|
const outlierPrices = identifyOutlierPrices(prices);
|
||||||
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
await db
|
||||||
|
.update(pricingObservations)
|
||||||
|
.set({ isOutlier: false, outlierReason: null })
|
||||||
|
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||||
if (outlierPrices.size) {
|
if (outlierPrices.size) {
|
||||||
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
|
await db
|
||||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
|
.update(pricingObservations)
|
||||||
|
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pricingObservations.inventoryItemId, inventoryItemId),
|
||||||
|
inArray(pricingObservations.priceMinor, [...outlierPrices]),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { status: 'UPDATED' };
|
return { status: "UPDATED" };
|
||||||
},
|
},
|
||||||
|
|
||||||
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) {
|
async createOverride({
|
||||||
|
inventoryItemId,
|
||||||
|
sellerId,
|
||||||
|
actorUserId,
|
||||||
|
valueMinor,
|
||||||
|
currency,
|
||||||
|
reason,
|
||||||
|
}) {
|
||||||
return db.transaction(async (transaction) => {
|
return db.transaction(async (transaction) => {
|
||||||
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems)
|
const [ownedItem] = await transaction
|
||||||
|
.select({ id: inventoryItems.id })
|
||||||
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
and(
|
||||||
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning();
|
eq(inventoryItems.id, inventoryItemId),
|
||||||
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } });
|
eq(products.sellerId, sellerId),
|
||||||
return { status: 'CREATED', override };
|
),
|
||||||
|
);
|
||||||
|
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||||
|
const [override] = await transaction
|
||||||
|
.insert(marketValueOverrides)
|
||||||
|
.values({
|
||||||
|
inventoryItemId,
|
||||||
|
valueMinor,
|
||||||
|
currency,
|
||||||
|
reason,
|
||||||
|
createdBy: actorUserId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
await transaction.insert(securityAuditEvents).values({
|
||||||
|
userId: actorUserId,
|
||||||
|
event: "MARKET_VALUE_OVERRIDDEN",
|
||||||
|
metadata: { inventoryItemId, overrideId: override.id, reason },
|
||||||
|
});
|
||||||
|
return { status: "CREATED", override };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js';
|
import {
|
||||||
|
pricingProviderMappings,
|
||||||
|
products,
|
||||||
|
} from "../../../infrastructure/database/schema.js";
|
||||||
|
|
||||||
export function createProviderMappingRepository(db) {
|
export function createProviderMappingRepository(db) {
|
||||||
return {
|
return {
|
||||||
async createForSeller(mapping, sellerId) {
|
async createForSeller(mapping, sellerId) {
|
||||||
const [ownedProduct] = await db.select({ id: products.id }).from(products)
|
const [ownedProduct] = await db
|
||||||
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId)));
|
.select({ id: products.id })
|
||||||
if (!ownedProduct) return { status: 'NOT_FOUND' };
|
.from(products)
|
||||||
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning();
|
.where(
|
||||||
return { status: 'CREATED', mapping: created };
|
and(
|
||||||
|
eq(products.id, mapping.productId),
|
||||||
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!ownedProduct) return { status: "NOT_FOUND" };
|
||||||
|
const [created] = await db
|
||||||
|
.insert(pricingProviderMappings)
|
||||||
|
.values(mapping)
|
||||||
|
.returning();
|
||||||
|
return { status: "CREATED", mapping: created };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listForSeller(productId, sellerId) {
|
async listForSeller(productId, sellerId) {
|
||||||
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
return db
|
||||||
|
.select({ mapping: pricingProviderMappings })
|
||||||
|
.from(pricingProviderMappings)
|
||||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||||
.where(and(
|
.where(
|
||||||
eq(pricingProviderMappings.productId, productId),
|
and(
|
||||||
eq(products.sellerId, sellerId),
|
eq(pricingProviderMappings.productId, productId),
|
||||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
eq(products.sellerId, sellerId),
|
||||||
))
|
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.then((rows) => rows.map(({ mapping }) => mapping));
|
.then((rows) => rows.map(({ mapping }) => mapping));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
|
|||||||
const conditions = [
|
const conditions = [
|
||||||
eq(pricingProviderMappings.productId, productId),
|
eq(pricingProviderMappings.productId, productId),
|
||||||
eq(products.sellerId, sellerId),
|
eq(products.sellerId, sellerId),
|
||||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||||
];
|
];
|
||||||
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
if (providerId)
|
||||||
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||||
|
const [row] = await db
|
||||||
|
.select({ mapping: pricingProviderMappings })
|
||||||
|
.from(pricingProviderMappings)
|
||||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
return row?.mapping ?? null;
|
return row?.mapping ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async disableForSeller(id, sellerId) {
|
async disableForSeller(id, sellerId) {
|
||||||
const [mapping] = await db.select().from(pricingProviderMappings)
|
const [mapping] = await db
|
||||||
|
.select()
|
||||||
|
.from(pricingProviderMappings)
|
||||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||||
.where(and(
|
.where(
|
||||||
eq(pricingProviderMappings.id, id),
|
and(
|
||||||
eq(products.sellerId, sellerId),
|
eq(pricingProviderMappings.id, id),
|
||||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
eq(products.sellerId, sellerId),
|
||||||
));
|
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||||
if (!mapping) return { status: 'NOT_FOUND' };
|
),
|
||||||
const [updated] = await db.update(pricingProviderMappings)
|
);
|
||||||
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() })
|
if (!mapping) return { status: "NOT_FOUND" };
|
||||||
|
const [updated] = await db
|
||||||
|
.update(pricingProviderMappings)
|
||||||
|
.set({
|
||||||
|
status: "DISABLED",
|
||||||
|
disabledAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
.where(eq(pricingProviderMappings.id, id))
|
.where(eq(pricingProviderMappings.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
return { status: 'DISABLED', mapping: updated };
|
return { status: "DISABLED", mapping: updated };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,85 @@
|
|||||||
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
|
import {
|
||||||
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
|
toAdminMarketValuationDto,
|
||||||
|
toAdminPricingObservationDto,
|
||||||
|
} from "@pokemon-inventory/contracts/pricing";
|
||||||
|
import {
|
||||||
|
createMarketValueOverrideSchema,
|
||||||
|
createPricingObservationSchema,
|
||||||
|
} from "@pokemon-inventory/validation/pricing";
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
function validationError(reply, result) {
|
function validationError(reply, result) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_ERROR',
|
code: "VALIDATION_ERROR",
|
||||||
message: 'Request validation failed.',
|
message: "Request validation failed.",
|
||||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
details: result.error.issues.map(({ message, path }) => ({
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAdminPricingRoutes(app, options) {
|
export async function registerAdminPricingRoutes(app, options) {
|
||||||
const {
|
const {
|
||||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
authenticateAdmin = async () => {
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
sessionCookieName = 'pokemon_inventory_session',
|
sessionCookieName = "pokemon_inventory_session",
|
||||||
pricingService,
|
pricingService,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
app.addHook('preHandler', async (request) => {
|
app.addHook("preHandler", async (request) => {
|
||||||
const admin = await authenticateAdmin(request);
|
const admin = await authenticateAdmin(request);
|
||||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
if (!admin?.sellerId)
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
request.admin = admin;
|
request.admin = admin;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/pricing/observations', async (request, reply) => {
|
app.post("/pricing/observations", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
const result = createPricingObservationSchema.safeParse(request.body);
|
const result = createPricingObservationSchema.safeParse(request.body);
|
||||||
if (!result.success) return validationError(reply, result);
|
if (!result.success) return validationError(reply, result);
|
||||||
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId });
|
const observation = await pricingService.addObservation({
|
||||||
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) });
|
...result.data,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
|
return reply
|
||||||
|
.code(201)
|
||||||
|
.send({ data: toAdminPricingObservationDto(observation) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/inventory/:id/valuation', async (request, reply) => {
|
app.post("/inventory/:id/valuation", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
|
const valuation = await pricingService.calculate({
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
inventoryItemId: request.params.id,
|
||||||
|
});
|
||||||
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/inventory/:id/valuation/override', async (request, reply) => {
|
app.post("/inventory/:id/valuation/override", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
const result = createMarketValueOverrideSchema.safeParse(request.body);
|
const result = createMarketValueOverrideSchema.safeParse(request.body);
|
||||||
if (!result.success) return validationError(reply, result);
|
if (!result.success) return validationError(reply, result);
|
||||||
const override = await pricingService.override({
|
const override = await pricingService.override({
|
||||||
@@ -54,8 +91,11 @@ export async function registerAdminPricingRoutes(app, options) {
|
|||||||
return reply.code(201).send({ data: override });
|
return reply.code(201).send({ data: override });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
|
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
const { providerId, productId, conditionId } = request.body ?? {};
|
const { providerId, productId, conditionId } = request.body ?? {};
|
||||||
const job = await pricingService.enqueueRefresh({
|
const job = await pricingService.enqueueRefresh({
|
||||||
sellerId: request.admin.sellerId,
|
sellerId: request.admin.sellerId,
|
||||||
@@ -64,11 +104,16 @@ export async function registerAdminPricingRoutes(app, options) {
|
|||||||
productId,
|
productId,
|
||||||
conditionId,
|
conditionId,
|
||||||
});
|
});
|
||||||
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
|
return reply.code(202).send({
|
||||||
|
data: { id: job.id, status: job.status, createdAt: job.createdAt },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/jobs/:id', async (request) => {
|
app.get("/jobs/:id", async (request) => {
|
||||||
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
|
const job = await pricingService.getJob({
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
jobId: request.params.id,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
@@ -81,16 +126,31 @@ export async function registerAdminPricingRoutes(app, options) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/jobs', async (request) => ({
|
app.get("/jobs", async (request) => ({
|
||||||
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({
|
data: (
|
||||||
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId,
|
await pricingService.listJobs({ sellerId: request.admin.sellerId })
|
||||||
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
|
).map((job) => ({
|
||||||
|
id: job.id,
|
||||||
|
status: job.status,
|
||||||
|
providerId: job.providerId,
|
||||||
|
inventoryItemId: job.inventoryItemId,
|
||||||
|
error: job.error ?? null,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post('/jobs/:id/retry', async (request, reply) => {
|
app.post("/jobs/:id/retry", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
|
request.cookies[sessionCookieName],
|
||||||
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
|
const job = await pricingService.retryJob({
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
jobId: request.params.id,
|
||||||
|
});
|
||||||
|
return reply.code(202).send({
|
||||||
|
data: { id: job.id, status: job.status, createdAt: job.createdAt },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,79 @@
|
|||||||
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping';
|
import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
|
||||||
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping';
|
import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
function validationError(reply, result) {
|
function validationError(reply, result) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_ERROR',
|
code: "VALIDATION_ERROR",
|
||||||
message: 'Request validation failed.',
|
message: "Request validation failed.",
|
||||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
details: result.error.issues.map(({ message, path }) => ({
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAdminProviderMappingRoutes(app, options) {
|
export async function registerAdminProviderMappingRoutes(app, options) {
|
||||||
const {
|
const {
|
||||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
authenticateAdmin = async () => {
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
sessionCookieName = 'pokemon_inventory_session',
|
sessionCookieName = "pokemon_inventory_session",
|
||||||
providerMappingService,
|
providerMappingService,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
app.addHook('preHandler', async (request) => {
|
app.addHook("preHandler", async (request) => {
|
||||||
const admin = await authenticateAdmin(request);
|
const admin = await authenticateAdmin(request);
|
||||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
if (!admin?.sellerId)
|
||||||
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
request.admin = admin;
|
request.admin = admin;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/products/:id/provider-mappings', async (request) => ({
|
app.get("/products/:id/provider-mappings", async (request) => ({
|
||||||
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto),
|
data: (
|
||||||
|
await providerMappingService.list({
|
||||||
|
productId: request.params.id,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
})
|
||||||
|
).map(toAdminProviderMappingDto),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post('/products/:id/provider-mappings', async (request, reply) => {
|
app.post("/products/:id/provider-mappings", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
const result = createProviderMappingSchema.safeParse(request.body);
|
const result = createProviderMappingSchema.safeParse(request.body);
|
||||||
if (!result.success) return validationError(reply, result);
|
if (!result.success) return validationError(reply, result);
|
||||||
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId });
|
const mapping = await providerMappingService.create({
|
||||||
|
...result.data,
|
||||||
|
productId: request.params.id,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
|
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/provider-mappings/:id/disable', async (request, reply) => {
|
app.post("/provider-mappings/:id/disable", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
await authorizeUnsafeRequest?.(
|
||||||
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId });
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers["x-csrf-token"],
|
||||||
|
);
|
||||||
|
const mapping = await providerMappingService.disable({
|
||||||
|
id: request.params.id,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
|
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,184 @@
|
|||||||
import { createPricingObservation } from '../domain/pricing-observation.js';
|
import { createPricingObservation } from "../domain/pricing-observation.js";
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
|
export function createPricingService({
|
||||||
|
pricingRepository,
|
||||||
|
pricingProviderRegistry,
|
||||||
|
pricingRefreshJobRepository,
|
||||||
|
providerMappingRepository,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
async addObservation({ sellerId, ...input }) {
|
async addObservation({ sellerId, ...input }) {
|
||||||
const observation = createPricingObservation(input);
|
const observation = createPricingObservation(input);
|
||||||
const result = await pricingRepository.addObservationForSeller({ observation, sellerId });
|
const result = await pricingRepository.addObservationForSeller({
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
observation,
|
||||||
|
sellerId,
|
||||||
|
});
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result.observation;
|
return result.observation;
|
||||||
},
|
},
|
||||||
|
|
||||||
async markOutliers({ sellerId, inventoryItemId }) {
|
async markOutliers({ sellerId, inventoryItemId }) {
|
||||||
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
const result = await pricingRepository.markOutliersForSeller({
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
sellerId,
|
||||||
|
inventoryItemId,
|
||||||
|
});
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) {
|
async override({
|
||||||
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400);
|
sellerId,
|
||||||
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400);
|
actorUserId,
|
||||||
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() });
|
inventoryItemId,
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
valueMinor,
|
||||||
|
currency = "USD",
|
||||||
|
reason,
|
||||||
|
}) {
|
||||||
|
if (!Number.isInteger(valueMinor) || valueMinor < 0)
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVALID_OVERRIDE_VALUE",
|
||||||
|
"Override value must be a non-negative integer minor unit.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
if (typeof reason !== "string" || reason.trim().length < 3)
|
||||||
|
throw createApplicationError(
|
||||||
|
"OVERRIDE_REASON_REQUIRED",
|
||||||
|
"An override reason is required.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const result = await pricingRepository.createOverride({
|
||||||
|
sellerId,
|
||||||
|
actorUserId,
|
||||||
|
inventoryItemId,
|
||||||
|
valueMinor,
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
reason: reason.trim(),
|
||||||
|
});
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result.override;
|
return result.override;
|
||||||
},
|
},
|
||||||
|
|
||||||
async calculate({ sellerId, inventoryItemId }) {
|
async calculate({ sellerId, inventoryItemId }) {
|
||||||
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
await pricingRepository.markOutliersForSeller({
|
||||||
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
|
sellerId,
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
inventoryItemId,
|
||||||
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
|
});
|
||||||
|
const result = await pricingRepository.calculateForSeller({
|
||||||
|
sellerId,
|
||||||
|
inventoryItemId,
|
||||||
|
});
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
if (result.status === "NO_DATA")
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRICING_DATA_UNAVAILABLE",
|
||||||
|
"No pricing observations are available.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
async refresh({
|
||||||
|
sellerId,
|
||||||
|
inventoryItemId,
|
||||||
|
providerId,
|
||||||
|
productId,
|
||||||
|
conditionId,
|
||||||
|
}) {
|
||||||
const provider = pricingProviderRegistry?.get(providerId);
|
const provider = pricingProviderRegistry?.get(providerId);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503);
|
throw createApplicationError(
|
||||||
|
"PRICING_PROVIDER_UNAVAILABLE",
|
||||||
|
"Pricing provider is not configured.",
|
||||||
|
503,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let providerObservations;
|
let providerObservations;
|
||||||
try {
|
try {
|
||||||
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
|
providerObservations = await provider.observe({
|
||||||
|
inventoryItemId,
|
||||||
|
productId,
|
||||||
|
conditionId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
|
throw createApplicationError(
|
||||||
|
"PRICING_PROVIDER_FAILED",
|
||||||
|
error.message,
|
||||||
|
502,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const observations = [];
|
const observations = [];
|
||||||
for (const input of providerObservations) {
|
for (const input of providerObservations) {
|
||||||
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId }));
|
observations.push(
|
||||||
|
await this.addObservation({
|
||||||
|
sellerId,
|
||||||
|
...input,
|
||||||
|
providerId: provider.id,
|
||||||
|
inventoryItemId,
|
||||||
|
productId,
|
||||||
|
conditionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) };
|
return {
|
||||||
|
observations,
|
||||||
|
valuation: await this.calculate({ sellerId, inventoryItemId }),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
async enqueueRefresh({
|
||||||
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
|
sellerId,
|
||||||
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId);
|
inventoryItemId,
|
||||||
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409);
|
providerId,
|
||||||
|
productId,
|
||||||
|
conditionId,
|
||||||
|
}) {
|
||||||
|
if (!pricingRefreshJobRepository)
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRICING_QUEUE_UNAVAILABLE",
|
||||||
|
"Pricing refresh queue is unavailable.",
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
const mapping = await providerMappingRepository?.findActiveForSeller(
|
||||||
|
productId,
|
||||||
|
sellerId,
|
||||||
|
providerId,
|
||||||
|
);
|
||||||
|
if (!mapping)
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
|
||||||
|
"No active pricing provider mapping exists for this product.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
const job = await pricingRefreshJobRepository.create({
|
const job = await pricingRefreshJobRepository.create({
|
||||||
sellerId,
|
sellerId,
|
||||||
inventoryItemId,
|
inventoryItemId,
|
||||||
providerId: mapping.providerId,
|
providerId: mapping.providerId,
|
||||||
|
externalIdentifier: mapping.externalIdentifier,
|
||||||
|
searchQuery: mapping.searchQuery,
|
||||||
productId,
|
productId,
|
||||||
conditionId,
|
conditionId,
|
||||||
});
|
});
|
||||||
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getJob({ sellerId, jobId }) {
|
async getJob({ sellerId, jobId }) {
|
||||||
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId);
|
const job = await pricingRefreshJobRepository?.findByIdForSeller(
|
||||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
|
jobId,
|
||||||
|
sellerId,
|
||||||
|
);
|
||||||
|
if (!job)
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRICING_JOB_NOT_FOUND",
|
||||||
|
"Pricing job was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return job;
|
return job;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -78,9 +204,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
|||||||
},
|
},
|
||||||
|
|
||||||
async retryJob({ sellerId, jobId }) {
|
async retryJob({ sellerId, jobId }) {
|
||||||
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
|
const job = await pricingRefreshJobRepository?.retryForSeller(
|
||||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
|
jobId,
|
||||||
|
sellerId,
|
||||||
|
);
|
||||||
|
if (!job)
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRICING_JOB_NOT_RETRYABLE",
|
||||||
|
"Pricing job was not found or is not failed.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
return job;
|
return job;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
export function createProviderMappingService({ providerMappingRepository }) {
|
export function createProviderMappingService({ providerMappingRepository }) {
|
||||||
return {
|
return {
|
||||||
async create({ sellerId, ...input }) {
|
async create({ sellerId, ...input }) {
|
||||||
const result = await providerMappingRepository.createForSeller(input, sellerId);
|
const result = await providerMappingRepository.createForSeller(
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404);
|
input,
|
||||||
|
sellerId,
|
||||||
|
);
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"PRODUCT_NOT_FOUND",
|
||||||
|
"Product was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result.mapping;
|
return result.mapping;
|
||||||
},
|
},
|
||||||
async list({ productId, sellerId }) {
|
async list({ productId, sellerId }) {
|
||||||
return providerMappingRepository.listForSeller(productId, sellerId);
|
return providerMappingRepository.listForSeller(productId, sellerId);
|
||||||
},
|
},
|
||||||
async disable({ id, sellerId }) {
|
async disable({ id, sellerId }) {
|
||||||
const result = await providerMappingRepository.disableForSeller(id, sellerId);
|
const result = await providerMappingRepository.disableForSeller(
|
||||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404);
|
id,
|
||||||
|
sellerId,
|
||||||
|
);
|
||||||
|
if (result.status === "NOT_FOUND")
|
||||||
|
throw createApplicationError(
|
||||||
|
"PROVIDER_MAPPING_NOT_FOUND",
|
||||||
|
"Provider mapping was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
return result.mapping;
|
return result.mapping;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function requiredText(value, fieldName) {
|
function requiredText(value, fieldName) {
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
throw new Error(`${fieldName} is required.`);
|
throw new Error(`${fieldName} is required.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
|
|||||||
const [, year, month, day] = match;
|
const [, year, month, day] = match;
|
||||||
const parsedDate = new Date(`${date}T00:00:00.000Z`);
|
const parsedDate = new Date(`${date}T00:00:00.000Z`);
|
||||||
if (
|
if (
|
||||||
Number.isNaN(parsedDate.getTime())
|
Number.isNaN(parsedDate.getTime()) ||
|
||||||
|| parsedDate.getUTCFullYear() !== Number(year)
|
parsedDate.getUTCFullYear() !== Number(year) ||
|
||||||
|| parsedDate.getUTCMonth() + 1 !== Number(month)
|
parsedDate.getUTCMonth() + 1 !== Number(month) ||
|
||||||
|| parsedDate.getUTCDate() !== Number(day)
|
parsedDate.getUTCDate() !== Number(day)
|
||||||
) {
|
) {
|
||||||
throw new Error(`${fieldName} must be a valid ISO date.`);
|
throw new Error(`${fieldName} must be a valid ISO date.`);
|
||||||
}
|
}
|
||||||
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requireCurrency(value) {
|
function requireCurrency(value) {
|
||||||
const currency = requiredText(value, 'Currency').toUpperCase();
|
const currency = requiredText(value, "Currency").toUpperCase();
|
||||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||||
throw new Error('Currency must be a three-letter ISO currency code.');
|
throw new Error("Currency must be a three-letter ISO currency code.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return currency;
|
return currency;
|
||||||
@@ -58,7 +58,7 @@ export function createSale({
|
|||||||
saleDate,
|
saleDate,
|
||||||
saleLocation,
|
saleLocation,
|
||||||
soldPriceMinor,
|
soldPriceMinor,
|
||||||
currency = 'USD',
|
currency = "USD",
|
||||||
platformFeesMinor = 0,
|
platformFeesMinor = 0,
|
||||||
paymentProcessingFeesMinor = 0,
|
paymentProcessingFeesMinor = 0,
|
||||||
shippingCostMinor = 0,
|
shippingCostMinor = 0,
|
||||||
@@ -69,42 +69,55 @@ export function createSale({
|
|||||||
notes,
|
notes,
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
sellerId: requiredText(sellerId, "Seller ID"),
|
||||||
saleDate: requireIsoDate(saleDate, 'Sale date'),
|
saleDate: requireIsoDate(saleDate, "Sale date"),
|
||||||
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
|
...(typeof saleLocation === "string" && saleLocation.trim().length > 0
|
||||||
? { saleLocation: saleLocation.trim() }
|
? { saleLocation: saleLocation.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||||
currency: requireCurrency(currency),
|
currency: requireCurrency(currency),
|
||||||
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
|
platformFeesMinor: requireNonNegativeMinorUnit(
|
||||||
|
platformFeesMinor,
|
||||||
|
"Platform fees",
|
||||||
|
),
|
||||||
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
||||||
paymentProcessingFeesMinor,
|
paymentProcessingFeesMinor,
|
||||||
'Payment processing fees',
|
"Payment processing fees",
|
||||||
),
|
),
|
||||||
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'),
|
shippingCostMinor: requireNonNegativeMinorUnit(
|
||||||
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'),
|
shippingCostMinor,
|
||||||
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'),
|
"Shipping cost",
|
||||||
...(typeof buyerName === 'string' && buyerName.trim().length > 0
|
),
|
||||||
|
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
|
||||||
|
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
|
||||||
|
...(typeof buyerName === "string" && buyerName.trim().length > 0
|
||||||
? { buyerName: buyerName.trim() }
|
? { buyerName: buyerName.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0
|
...(typeof buyerContact === "string" && buyerContact.trim().length > 0
|
||||||
? { buyerContact: buyerContact.trim() }
|
? { buyerContact: buyerContact.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}),
|
...(typeof notes === "string" && notes.trim().length > 0
|
||||||
|
? { notes: notes.trim() }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
|
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
|
||||||
return {
|
return {
|
||||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
|
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
|
export function createSaleItem({
|
||||||
|
saleId,
|
||||||
|
inventoryItemId,
|
||||||
|
soldPriceMinor,
|
||||||
|
costBasisMinor,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
saleId: requiredText(saleId, 'Sale ID'),
|
saleId: requiredText(saleId, "Sale ID"),
|
||||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||||
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
|
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
|
|||||||
taxesMinor = 0,
|
taxesMinor = 0,
|
||||||
otherFeesMinor = 0,
|
otherFeesMinor = 0,
|
||||||
}) {
|
}) {
|
||||||
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price');
|
const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
|
||||||
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees');
|
const platformFees = requireNonNegativeMinorUnit(
|
||||||
|
platformFeesMinor,
|
||||||
|
"Platform fees",
|
||||||
|
);
|
||||||
const processingFees = requireNonNegativeMinorUnit(
|
const processingFees = requireNonNegativeMinorUnit(
|
||||||
paymentProcessingFeesMinor,
|
paymentProcessingFeesMinor,
|
||||||
'Payment processing fees',
|
"Payment processing fees",
|
||||||
);
|
);
|
||||||
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost');
|
const shipping = requireNonNegativeMinorUnit(
|
||||||
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes');
|
shippingCostMinor,
|
||||||
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees');
|
"Shipping cost",
|
||||||
|
);
|
||||||
|
const taxes = requireNonNegativeMinorUnit(taxesMinor, "Taxes");
|
||||||
|
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, "Other fees");
|
||||||
|
|
||||||
return salePrice - platformFees - processingFees - shipping - taxes - otherFees;
|
return (
|
||||||
|
salePrice - platformFees - processingFees - shipping - taxes - otherFees
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
inventoryItems,
|
inventoryItems,
|
||||||
@@ -6,14 +6,20 @@ import {
|
|||||||
saleItems,
|
saleItems,
|
||||||
sales,
|
sales,
|
||||||
securityAuditEvents,
|
securityAuditEvents,
|
||||||
} from '../../../infrastructure/database/schema.js';
|
} from "../../../infrastructure/database/schema.js";
|
||||||
|
|
||||||
export function createSaleRepository(db) {
|
export function createSaleRepository(db) {
|
||||||
return {
|
return {
|
||||||
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
|
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
|
||||||
return this.createForInventoryItems({
|
return this.createForInventoryItems({
|
||||||
sale,
|
sale,
|
||||||
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }],
|
items: [
|
||||||
|
{
|
||||||
|
inventoryItemId,
|
||||||
|
soldPriceMinor: sale.soldPriceMinor,
|
||||||
|
costBasisMinor,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -23,33 +29,57 @@ export function createSaleRepository(db) {
|
|||||||
.select({ id: inventoryItems.id, status: inventoryItems.status })
|
.select({ id: inventoryItems.id, status: inventoryItems.status })
|
||||||
.from(inventoryItems)
|
.from(inventoryItems)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(
|
.where(
|
||||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
and(
|
||||||
eq(products.sellerId, sale.sellerId),
|
inArray(
|
||||||
));
|
inventoryItems.id,
|
||||||
|
items.map((item) => item.inventoryItemId),
|
||||||
|
),
|
||||||
|
eq(products.sellerId, sale.sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' };
|
if (ownedInventoryItems.length !== items.length)
|
||||||
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE');
|
return { status: "NOT_FOUND" };
|
||||||
|
const unavailableItem = ownedInventoryItems.find(
|
||||||
|
(item) => item.status !== "ACTIVE",
|
||||||
|
);
|
||||||
if (unavailableItem) {
|
if (unavailableItem) {
|
||||||
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status };
|
return {
|
||||||
|
status: "NOT_ACTIVE",
|
||||||
|
inventoryStatus: unavailableItem.status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [createdSale] = await transaction.insert(sales).values(sale).returning();
|
const [createdSale] = await transaction
|
||||||
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({
|
.insert(sales)
|
||||||
saleId: createdSale.id,
|
.values(sale)
|
||||||
...item,
|
.returning();
|
||||||
}))).returning();
|
const createdSaleItems = await transaction
|
||||||
|
.insert(saleItems)
|
||||||
|
.values(
|
||||||
|
items.map((item) => ({
|
||||||
|
saleId: createdSale.id,
|
||||||
|
...item,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
const updatedInventoryItems = await transaction
|
const updatedInventoryItems = await transaction
|
||||||
.update(inventoryItems)
|
.update(inventoryItems)
|
||||||
.set({ status: 'SOLD', updatedAt: new Date() })
|
.set({ status: "SOLD", updatedAt: new Date() })
|
||||||
.where(and(
|
.where(
|
||||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
and(
|
||||||
eq(inventoryItems.status, 'ACTIVE'),
|
inArray(
|
||||||
))
|
inventoryItems.id,
|
||||||
|
items.map((item) => item.inventoryItemId),
|
||||||
|
),
|
||||||
|
eq(inventoryItems.status, "ACTIVE"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'CREATED',
|
status: "CREATED",
|
||||||
sale: createdSale,
|
sale: createdSale,
|
||||||
saleItems: createdSaleItems,
|
saleItems: createdSaleItems,
|
||||||
saleItem: createdSaleItems[0],
|
saleItem: createdSaleItems[0],
|
||||||
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
|
|||||||
})
|
})
|
||||||
.from(sales)
|
.from(sales)
|
||||||
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, saleItems.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(eq(sales.sellerId, sellerId))
|
.where(eq(sales.sellerId, sellerId))
|
||||||
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
|
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
|
||||||
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
|
|||||||
productNames: [],
|
productNames: [],
|
||||||
};
|
};
|
||||||
current.saleItems.push(row.saleItem);
|
current.saleItems.push(row.saleItem);
|
||||||
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus });
|
current.inventoryItems.push({
|
||||||
|
id: row.saleItem.inventoryItemId,
|
||||||
|
status: row.inventoryStatus,
|
||||||
|
});
|
||||||
current.productNames.push(row.productName);
|
current.productNames.push(row.productName);
|
||||||
grouped.set(row.sale.id, current);
|
grouped.set(row.sale.id, current);
|
||||||
}
|
}
|
||||||
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
|
|||||||
...sale,
|
...sale,
|
||||||
saleItem: sale.saleItems[0],
|
saleItem: sale.saleItems[0],
|
||||||
inventoryItem: sale.inventoryItems[0],
|
inventoryItem: sale.inventoryItems[0],
|
||||||
productName: sale.productNames.join(', '),
|
productName: sale.productNames.join(", "),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
|
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
|
||||||
return db.transaction(async (transaction) => {
|
return db.transaction(async (transaction) => {
|
||||||
const saleRecords = await transaction
|
const saleRecords = await transaction
|
||||||
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status })
|
.select({
|
||||||
|
sale: sales,
|
||||||
|
saleItem: saleItems,
|
||||||
|
inventoryStatus: inventoryItems.status,
|
||||||
|
})
|
||||||
.from(sales)
|
.from(sales)
|
||||||
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||||
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
|
.innerJoin(
|
||||||
|
inventoryItems,
|
||||||
|
eq(inventoryItems.id, saleItems.inventoryItemId),
|
||||||
|
)
|
||||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||||
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sales.id, saleId),
|
||||||
|
eq(sales.sellerId, sellerId),
|
||||||
|
eq(products.sellerId, sellerId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (!saleRecords.length) return { status: 'NOT_FOUND' };
|
if (!saleRecords.length) return { status: "NOT_FOUND" };
|
||||||
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' };
|
if (saleRecords[0].sale.status === "VOIDED")
|
||||||
|
return { status: "ALREADY_VOIDED" };
|
||||||
const saleRecord = saleRecords[0];
|
const saleRecord = saleRecords[0];
|
||||||
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId);
|
const inventoryItemIds = saleRecords.map(
|
||||||
|
({ saleItem }) => saleItem.inventoryItemId,
|
||||||
|
);
|
||||||
|
|
||||||
const [updatedSale] = await transaction
|
const [updatedSale] = await transaction
|
||||||
.update(sales)
|
.update(sales)
|
||||||
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() })
|
.set({
|
||||||
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED')))
|
status: "VOIDED",
|
||||||
|
voidReason: reason,
|
||||||
|
voidedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
|
||||||
.returning();
|
.returning();
|
||||||
const [updatedInventoryItem] = await transaction
|
const [updatedInventoryItem] = await transaction
|
||||||
.update(inventoryItems)
|
.update(inventoryItems)
|
||||||
.set({ status: 'ACTIVE', updatedAt: new Date() })
|
.set({ status: "ACTIVE", updatedAt: new Date() })
|
||||||
.where(and(
|
.where(
|
||||||
inArray(inventoryItems.id, inventoryItemIds),
|
and(
|
||||||
eq(inventoryItems.status, 'SOLD'),
|
inArray(inventoryItems.id, inventoryItemIds),
|
||||||
))
|
eq(inventoryItems.status, "SOLD"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
await transaction.insert(securityAuditEvents).values({
|
await transaction.insert(securityAuditEvents).values({
|
||||||
userId: actorUserId,
|
userId: actorUserId,
|
||||||
event: 'SALE_VOIDED',
|
event: "SALE_VOIDED",
|
||||||
metadata: { saleId, inventoryItemIds, reason },
|
metadata: { saleId, inventoryItemIds, reason },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'VOIDED',
|
status: "VOIDED",
|
||||||
sale: updatedSale,
|
sale: updatedSale,
|
||||||
saleItems: saleRecords.map(({ saleItem }) => saleItem),
|
saleItems: saleRecords.map(({ saleItem }) => saleItem),
|
||||||
saleItem: saleRecord.saleItem,
|
saleItem: saleRecord.saleItem,
|
||||||
@@ -139,4 +198,4 @@ export function createSaleRepository(db) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,64 @@
|
|||||||
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
|
import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
|
||||||
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
|
import {
|
||||||
|
createMultiItemSaleSchema,
|
||||||
|
createSaleSchema,
|
||||||
|
voidSaleSchema,
|
||||||
|
} from "@pokemon-inventory/validation/sale";
|
||||||
|
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
function validationError(reply, result) {
|
function validationError(reply, result) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_ERROR',
|
code: "VALIDATION_ERROR",
|
||||||
message: 'Request validation failed.',
|
message: "Request validation failed.",
|
||||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
details: result.error.issues.map(({ message, path }) => ({
|
||||||
|
message,
|
||||||
|
path,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultAuthenticateAdmin() {
|
async function defaultAuthenticateAdmin() {
|
||||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAdminSaleRoutes(app, options) {
|
export async function registerAdminSaleRoutes(app, options) {
|
||||||
const {
|
const {
|
||||||
authenticateAdmin = defaultAuthenticateAdmin,
|
authenticateAdmin = defaultAuthenticateAdmin,
|
||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
csrfCookieName = 'pokemon_inventory_csrf',
|
csrfCookieName = "pokemon_inventory_csrf",
|
||||||
sessionCookieName = 'pokemon_inventory_session',
|
sessionCookieName = "pokemon_inventory_session",
|
||||||
saleService,
|
saleService,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
app.addHook('preHandler', async (request) => {
|
app.addHook("preHandler", async (request) => {
|
||||||
const admin = await authenticateAdmin(request);
|
const admin = await authenticateAdmin(request);
|
||||||
|
|
||||||
if (!admin?.sellerId) {
|
if (!admin?.sellerId) {
|
||||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
throw createApplicationError(
|
||||||
|
"UNAUTHORIZED",
|
||||||
|
"Authentication is required.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.admin = admin;
|
request.admin = admin;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/sales', async (request, reply) => {
|
app.post("/sales", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(
|
await authorizeUnsafeRequest?.(
|
||||||
request.cookies[sessionCookieName],
|
request.cookies[sessionCookieName],
|
||||||
request.headers['x-csrf-token'],
|
request.headers["x-csrf-token"],
|
||||||
);
|
);
|
||||||
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
|
const schema = Array.isArray(request.body?.items)
|
||||||
|
? createMultiItemSaleSchema
|
||||||
|
: createSaleSchema;
|
||||||
const result = schema.safeParse(request.body);
|
const result = schema.safeParse(request.body);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
|
|||||||
return reply.code(201).send({ data: toAdminSaleDto(sale) });
|
return reply.code(201).send({ data: toAdminSaleDto(sale) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/sales', async (request) => ({
|
app.get("/sales", async (request) => ({
|
||||||
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
|
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
|
||||||
|
toAdminSaleDto,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post('/sales/:id/void', async (request, reply) => {
|
app.post("/sales/:id/void", async (request, reply) => {
|
||||||
await authorizeUnsafeRequest?.(
|
await authorizeUnsafeRequest?.(
|
||||||
request.cookies[sessionCookieName],
|
request.cookies[sessionCookieName],
|
||||||
request.headers['x-csrf-token'],
|
request.headers["x-csrf-token"],
|
||||||
);
|
);
|
||||||
const result = voidSaleSchema.safeParse(request.body);
|
const result = voidSaleSchema.safeParse(request.body);
|
||||||
if (!result.success) return validationError(reply, result);
|
if (!result.success) return validationError(reply, result);
|
||||||
@@ -76,4 +95,4 @@ export async function registerAdminSaleRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
return reply.code(200).send({ data: toAdminSaleDto(sale) });
|
return reply.code(200).send({ data: toAdminSaleDto(sale) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,62 @@
|
|||||||
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
|
import {
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
calculateNetSaleProceeds,
|
||||||
|
createSale,
|
||||||
|
validateSaleItem,
|
||||||
|
} from "../domain/sale.js";
|
||||||
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
export function createSaleService({ saleRepository }) {
|
export function createSaleService({ saleRepository }) {
|
||||||
return {
|
return {
|
||||||
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
|
async create({
|
||||||
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
|
sellerId,
|
||||||
|
inventoryItemId,
|
||||||
|
costBasisMinor,
|
||||||
|
items,
|
||||||
|
...input
|
||||||
|
}) {
|
||||||
|
const saleItems = items ?? [
|
||||||
|
{
|
||||||
|
inventoryItemId,
|
||||||
|
soldPriceMinor: input.soldPriceMinor,
|
||||||
|
costBasisMinor,
|
||||||
|
},
|
||||||
|
];
|
||||||
const totalSoldPriceMinor = items
|
const totalSoldPriceMinor = items
|
||||||
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
|
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
|
||||||
: input.soldPriceMinor;
|
: input.soldPriceMinor;
|
||||||
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
|
const sale = createSale({
|
||||||
|
sellerId,
|
||||||
|
...input,
|
||||||
|
soldPriceMinor: totalSoldPriceMinor,
|
||||||
|
});
|
||||||
const validatedItems = saleItems.map((item) => ({
|
const validatedItems = saleItems.map((item) => ({
|
||||||
inventoryItemId: item.inventoryItemId,
|
inventoryItemId: item.inventoryItemId,
|
||||||
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
|
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
|
||||||
costBasisMinor: validateSaleItem(item).costBasisMinor,
|
costBasisMinor: validateSaleItem(item).costBasisMinor,
|
||||||
}));
|
}));
|
||||||
const result = items
|
const result = items
|
||||||
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
|
? await saleRepository.createForInventoryItems({
|
||||||
|
sale,
|
||||||
|
items: validatedItems,
|
||||||
|
})
|
||||||
: await saleRepository.createForInventoryItem({
|
: await saleRepository.createForInventoryItem({
|
||||||
sale,
|
sale,
|
||||||
inventoryItemId,
|
inventoryItemId,
|
||||||
costBasisMinor: validatedItems[0].costBasisMinor,
|
costBasisMinor: validatedItems[0].costBasisMinor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status === 'NOT_FOUND') {
|
if (result.status === "NOT_FOUND") {
|
||||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
throw createApplicationError(
|
||||||
|
"INVENTORY_NOT_FOUND",
|
||||||
|
"Inventory item was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'NOT_ACTIVE') {
|
if (result.status === "NOT_ACTIVE") {
|
||||||
throw createApplicationError(
|
throw createApplicationError(
|
||||||
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
|
"INVENTORY_NOT_AVAILABLE_FOR_SALE",
|
||||||
'Inventory item is not available for sale.',
|
"Inventory item is not available for sale.",
|
||||||
409,
|
409,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async void({ saleId, sellerId, actorUserId, reason }) {
|
async void({ saleId, sellerId, actorUserId, reason }) {
|
||||||
if (typeof reason !== 'string' || reason.trim().length < 3) {
|
if (typeof reason !== "string" || reason.trim().length < 3) {
|
||||||
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
|
throw createApplicationError(
|
||||||
|
"SALE_VOID_REASON_REQUIRED",
|
||||||
|
"A void reason is required.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await saleRepository.voidForSeller({
|
const result = await saleRepository.voidForSeller({
|
||||||
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
|
|||||||
actorUserId,
|
actorUserId,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
});
|
});
|
||||||
if (result.status === 'NOT_FOUND') {
|
if (result.status === "NOT_FOUND") {
|
||||||
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
|
throw createApplicationError(
|
||||||
|
"SALE_NOT_FOUND",
|
||||||
|
"Sale was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (result.status === 'ALREADY_VOIDED') {
|
if (result.status === "ALREADY_VOIDED") {
|
||||||
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
|
throw createApplicationError(
|
||||||
|
"SALE_ALREADY_VOIDED",
|
||||||
|
"Sale is already voided.",
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return withFinancialSummary(result);
|
return withFinancialSummary(result);
|
||||||
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withFinancialSummary(result) {
|
function withFinancialSummary(result) {
|
||||||
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
|
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
|
||||||
const saleItems = result.saleItems ?? [result.saleItem];
|
const saleItems = result.saleItems ?? [result.saleItem];
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
netProceedsMinor,
|
netProceedsMinor,
|
||||||
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
|
profitMinor:
|
||||||
|
netProceedsMinor -
|
||||||
|
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function requiredString(value, fieldName) {
|
function requiredString(value, fieldName) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== "string") {
|
||||||
throw new Error(`${fieldName} must be a string.`);
|
throw new Error(`${fieldName} must be a string.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,16 +7,16 @@ function requiredString(value, fieldName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSearchQuery(value) {
|
export function normalizeSearchQuery(value) {
|
||||||
return requiredString(value, 'Search query')
|
return requiredString(value, "Search query")
|
||||||
.trim()
|
.trim()
|
||||||
.normalize('NFKC')
|
.normalize("NFKC")
|
||||||
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
|
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, " ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSearchQuery({
|
export function createSearchQuery({
|
||||||
query = '',
|
query = "",
|
||||||
category,
|
category,
|
||||||
conditionId,
|
conditionId,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
@@ -24,54 +24,68 @@ export function createSearchQuery({
|
|||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
includeSold = false,
|
includeSold = false,
|
||||||
sort = 'recent',
|
sort = "recent",
|
||||||
}) {
|
}) {
|
||||||
const text = normalizeSearchQuery(query);
|
const text = normalizeSearchQuery(query);
|
||||||
|
|
||||||
if (!Number.isInteger(page) || page < 1) {
|
if (!Number.isInteger(page) || page < 1) {
|
||||||
throw new Error('page must be >= 1');
|
throw new Error("page must be >= 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||||
throw new Error('limit must be between 1 and 100');
|
throw new Error("limit must be between 1 and 100");
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) {
|
|
||||||
throw new Error('minPriceMinor must be a non-negative integer');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
|
|
||||||
throw new Error('maxPriceMinor must be a non-negative integer');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof minPriceMinor !== 'undefined' &&
|
typeof minPriceMinor !== "undefined" &&
|
||||||
typeof maxPriceMinor !== 'undefined' &&
|
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
||||||
minPriceMinor > maxPriceMinor
|
|
||||||
) {
|
) {
|
||||||
throw new Error('minPriceMinor must be <= maxPriceMinor');
|
throw new Error("minPriceMinor must be a non-negative integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['recent', 'price_low', 'price_high'].includes(sort)) {
|
if (
|
||||||
throw new Error('sort must be recent, price_low, or price_high');
|
typeof maxPriceMinor !== "undefined" &&
|
||||||
|
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
|
||||||
|
) {
|
||||||
|
throw new Error("maxPriceMinor must be a non-negative integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof minPriceMinor !== "undefined" &&
|
||||||
|
typeof maxPriceMinor !== "undefined" &&
|
||||||
|
minPriceMinor > maxPriceMinor
|
||||||
|
) {
|
||||||
|
throw new Error("minPriceMinor must be <= maxPriceMinor");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["recent", "price_low", "price_high"].includes(sort)) {
|
||||||
|
throw new Error("sort must be recent, price_low, or price_high");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}),
|
...(typeof category === "string" && category.trim().length > 0
|
||||||
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}),
|
? { category: category.trim() }
|
||||||
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}),
|
: {}),
|
||||||
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}),
|
...(typeof conditionId === "string" && conditionId.trim().length > 0
|
||||||
|
? { conditionId: conditionId.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
|
||||||
|
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
includeSold,
|
includeSold,
|
||||||
...(sort !== 'recent' ? { sort } : {}),
|
...(sort !== "recent" ? { sort } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) {
|
export function buildSearchFilters({
|
||||||
|
includeSold = false,
|
||||||
|
status = "AVAILABLE",
|
||||||
|
sort = ["recently_added"],
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
status: includeSold ? 'ALL' : status,
|
status: includeSold ? "ALL" : status,
|
||||||
includeSold,
|
includeSold,
|
||||||
sort,
|
sort,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||||
|
|
||||||
function coerceNumber(value, fieldName, fallback) {
|
function coerceNumber(value, fieldName, fallback) {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Number.parseInt(String(value), 10);
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
|
if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
|
||||||
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400);
|
throw createApplicationError(
|
||||||
|
"INVALID_SEARCH_FILTER",
|
||||||
|
`${fieldName} must be an integer.`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
|
|||||||
export async function registerPublicSearchRoutes(app, options) {
|
export async function registerPublicSearchRoutes(app, options) {
|
||||||
const { searchService } = options;
|
const { searchService } = options;
|
||||||
|
|
||||||
app.get('/public/search', async (request) => {
|
app.get("/public/search", async (request) => {
|
||||||
if (!searchService || typeof searchService.searchPublic !== 'function') {
|
if (!searchService || typeof searchService.searchPublic !== "function") {
|
||||||
throw createApplicationError(
|
throw createApplicationError(
|
||||||
'SEARCH_SERVICE_UNAVAILABLE',
|
"SEARCH_SERVICE_UNAVAILABLE",
|
||||||
'Public search is currently unavailable.',
|
"Public search is currently unavailable.",
|
||||||
503,
|
503,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,14 +32,24 @@ export async function registerPublicSearchRoutes(app, options) {
|
|||||||
query: request.query.query,
|
query: request.query.query,
|
||||||
category: request.query.category,
|
category: request.query.category,
|
||||||
conditionId: request.query.conditionId,
|
conditionId: request.query.conditionId,
|
||||||
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined),
|
minPriceMinor: coerceNumber(
|
||||||
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined),
|
request.query.minPriceMinor,
|
||||||
page: coerceNumber(request.query.page, 'page', 1),
|
"minPriceMinor",
|
||||||
limit: coerceNumber(request.query.limit, 'limit', 20),
|
undefined,
|
||||||
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true,
|
),
|
||||||
sort: request.query.sort ?? 'recent',
|
maxPriceMinor: coerceNumber(
|
||||||
|
request.query.maxPriceMinor,
|
||||||
|
"maxPriceMinor",
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
page: coerceNumber(request.query.page, "page", 1),
|
||||||
|
limit: coerceNumber(request.query.limit, "limit", 20),
|
||||||
|
includeSold:
|
||||||
|
request.query.includeSold === "true" ||
|
||||||
|
request.query.includeSold === true,
|
||||||
|
sort: request.query.sort ?? "recent",
|
||||||
};
|
};
|
||||||
|
|
||||||
return { data: await searchService.searchPublic(filters) };
|
return { data: await searchService.searchPublic(filters) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js';
|
import { createAcquisitionService } from "../../src/modules/acquisitions/services/acquisition-service.js";
|
||||||
|
|
||||||
test('creates an owned acquisition and calculates cost basis', async () => {
|
test("creates an owned acquisition and calculates cost basis", async () => {
|
||||||
let received;
|
let received;
|
||||||
const service = createAcquisitionService({
|
const service = createAcquisitionService({
|
||||||
acquisitionRepository: {
|
acquisitionRepository: {
|
||||||
async createForSeller(input) {
|
async createForSeller(input) {
|
||||||
received = input;
|
received = input;
|
||||||
return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } };
|
return {
|
||||||
|
status: "CREATED",
|
||||||
|
acquisition: { id: "acquisition-1", ...input.acquisition },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.create({
|
const result = await service.create({
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
inventoryItemId: 'inventory-1',
|
inventoryItemId: "inventory-1",
|
||||||
method: 'PURCHASE',
|
method: "PURCHASE",
|
||||||
costMinor: 1000,
|
costMinor: 1000,
|
||||||
shippingMinor: 150,
|
shippingMinor: 150,
|
||||||
taxMinor: 75,
|
taxMinor: 75,
|
||||||
@@ -25,17 +28,27 @@ test('creates an owned acquisition and calculates cost basis', async () => {
|
|||||||
estimatedTradeValueMinor: 200,
|
estimatedTradeValueMinor: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(received.sellerId, 'seller-1');
|
assert.equal(received.sellerId, "seller-1");
|
||||||
assert.equal(result.costBasisMinor, 1075);
|
assert.equal(result.costBasisMinor, 1075);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects acquisition for an inventory item outside the seller account', async () => {
|
test("rejects acquisition for an inventory item outside the seller account", async () => {
|
||||||
const service = createAcquisitionService({
|
const service = createAcquisitionService({
|
||||||
acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } },
|
acquisitionRepository: {
|
||||||
|
async createForSeller() {
|
||||||
|
return { status: "NOT_FOUND" };
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }),
|
() =>
|
||||||
(error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404,
|
service.create({
|
||||||
|
sellerId: "seller-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
method: "PURCHASE",
|
||||||
|
costMinor: 100,
|
||||||
|
}),
|
||||||
|
(error) => error.code === "INVENTORY_NOT_FOUND" && error.statusCode === 404,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,59 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js';
|
import { createDashboardService } from "../../src/modules/dashboard/services/dashboard-service.js";
|
||||||
|
|
||||||
test('aggregates seller dashboard counts and financial totals', async () => {
|
test("aggregates seller dashboard counts and financial totals", async () => {
|
||||||
const service = createDashboardService({
|
const service = createDashboardService({
|
||||||
productService: { async listBySellerId() { return [{ id: 'product-1' }]; } },
|
productService: {
|
||||||
inventoryItemService: { async listBySellerId() { return [{ id: 'item-1', status: 'ACTIVE' }, { id: 'item-2', status: 'SOLD' }]; } },
|
async listBySellerId() {
|
||||||
listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } },
|
return [{ id: "product-1" }];
|
||||||
saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } },
|
},
|
||||||
acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } },
|
},
|
||||||
pricingRepository: { async listLatestValuationsForSeller() { return [{ inventoryItemId: 'item-1', valueMinor: 2800, calculatedAt: new Date() }]; } },
|
inventoryItemService: {
|
||||||
pricingRefreshJobRepository: { async countFailedForSeller() { return 2; } },
|
async listBySellerId() {
|
||||||
|
return [
|
||||||
|
{ id: "item-1", status: "ACTIVE" },
|
||||||
|
{ id: "item-2", status: "SOLD" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listingService: {
|
||||||
|
async list() {
|
||||||
|
return [{ status: "AVAILABLE", askingPriceMinor: 2500, imageCount: 1 }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
saleService: {
|
||||||
|
async list() {
|
||||||
|
return [
|
||||||
|
{ status: "COMPLETED", netProceedsMinor: 2200, profitMinor: 700 },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acquisitionService: {
|
||||||
|
async list() {
|
||||||
|
return [{ costBasisMinor: 1500 }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pricingRepository: {
|
||||||
|
async listLatestValuationsForSeller() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
inventoryItemId: "item-1",
|
||||||
|
valueMinor: 2800,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pricingRefreshJobRepository: {
|
||||||
|
async countFailedForSeller() {
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dashboard = await service.get({ sellerId: 'seller-1' });
|
const dashboard = await service.get({ sellerId: "seller-1" });
|
||||||
assert.deepEqual(dashboard.counts, {
|
assert.deepEqual(dashboard.counts, {
|
||||||
products: 1,
|
products: 1,
|
||||||
inventory: 2,
|
inventory: 2,
|
||||||
@@ -35,4 +74,4 @@ test('aggregates seller dashboard counts and financial totals', async () => {
|
|||||||
totalProfitMinor: 700,
|
totalProfitMinor: 700,
|
||||||
totalMarketValueMinor: 2800,
|
totalMarketValueMinor: 2800,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,87 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js';
|
import {
|
||||||
import { createFixturePricingProvider } from '../../src/modules/pricing/providers/fixture-pricing-provider.js';
|
createPricingProvider,
|
||||||
|
createPricingProviderRegistry,
|
||||||
|
} from "../../src/modules/pricing/providers/pricing-provider.js";
|
||||||
|
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
|
||||||
|
import { createHttpPricingProvider } from "../../src/modules/pricing/providers/http-pricing-provider.js";
|
||||||
|
|
||||||
test('registers pricing providers behind a validated adapter contract', async () => {
|
test("registers pricing providers behind a validated adapter contract", async () => {
|
||||||
const provider = createPricingProvider({
|
const provider = createPricingProvider({
|
||||||
id: 'example',
|
id: "example",
|
||||||
async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; },
|
async observe() {
|
||||||
|
return [{ priceMinor: 2500, sourceIdentifier: "example-1" }];
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const registry = createPricingProviderRegistry([provider]);
|
const registry = createPricingProviderRegistry([provider]);
|
||||||
|
|
||||||
assert.equal(registry.get('example'), provider);
|
assert.equal(registry.get("example"), provider);
|
||||||
assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]);
|
assert.deepEqual(await registry.get("example").observe({}), [
|
||||||
|
{ priceMinor: 2500, sourceIdentifier: "example-1" },
|
||||||
|
]);
|
||||||
assert.deepEqual(registry.list(), [provider]);
|
assert.deepEqual(registry.list(), [provider]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects malformed provider adapters and responses', async () => {
|
test("rejects malformed provider adapters and responses", async () => {
|
||||||
assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/);
|
assert.throws(
|
||||||
const provider = createPricingProvider({ id: 'broken', async observe() { return null; } });
|
() => createPricingProvider({ id: "broken" }),
|
||||||
await assert.rejects(() => provider.observe({}), /must return an observation array/);
|
/observe function is required/,
|
||||||
|
);
|
||||||
|
const provider = createPricingProvider({
|
||||||
|
id: "broken",
|
||||||
|
async observe() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => provider.observe({}),
|
||||||
|
/must return an observation array/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fixture provider returns deterministic normalized observations', async () => {
|
test("fixture provider returns deterministic normalized observations", async () => {
|
||||||
const [observation] = await createFixturePricingProvider().observe({
|
const [observation] = await createFixturePricingProvider().observe({
|
||||||
productId: 'product-1', inventoryItemId: 'item-1', conditionId: 'condition-1',
|
productId: "product-1",
|
||||||
|
inventoryItemId: "item-1",
|
||||||
|
conditionId: "condition-1",
|
||||||
});
|
});
|
||||||
assert.deepEqual(observation, {
|
assert.deepEqual(observation, {
|
||||||
productId: 'product-1',
|
productId: "product-1",
|
||||||
inventoryItemId: 'item-1',
|
inventoryItemId: "item-1",
|
||||||
conditionId: 'condition-1',
|
conditionId: "condition-1",
|
||||||
priceMinor: 2500,
|
priceMinor: 2500,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
sourceIdentifier: 'fixture-item-1',
|
sourceIdentifier: "fixture-item-1",
|
||||||
qualityScore: 50,
|
qualityScore: 50,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("http provider sends mapping context and normalizes its response", async () => {
|
||||||
|
let request;
|
||||||
|
const provider = createHttpPricingProvider({
|
||||||
|
id: "http-provider",
|
||||||
|
endpoint: "https://provider.example/prices",
|
||||||
|
token: "test-token",
|
||||||
|
async fetchImplementation(url, options) {
|
||||||
|
request = { url: String(url), options };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { observations: [{ priceMinor: 2500 }] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await provider.observe({
|
||||||
|
externalIdentifier: "catalog-1",
|
||||||
|
searchQuery: "Charizard",
|
||||||
|
}),
|
||||||
|
[{ priceMinor: 2500 }],
|
||||||
|
);
|
||||||
|
assert.match(request.url, /externalIdentifier=catalog-1/);
|
||||||
|
assert.equal(request.options.headers.authorization, "Bearer test-token");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,77 +1,137 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js';
|
import { createPricingService } from "../../src/modules/pricing/services/pricing-service.js";
|
||||||
|
|
||||||
test('records an owned pricing observation and calculates a valuation', async () => {
|
test("records an owned pricing observation and calculates a valuation", async () => {
|
||||||
const service = createPricingService({
|
const service = createPricingService({
|
||||||
pricingRepository: {
|
pricingRepository: {
|
||||||
async addObservationForSeller({ observation, sellerId }) {
|
async addObservationForSeller({ observation, sellerId }) {
|
||||||
assert.equal(sellerId, 'seller-1');
|
assert.equal(sellerId, "seller-1");
|
||||||
return { status: 'CREATED', observation };
|
return { status: "CREATED", observation };
|
||||||
},
|
},
|
||||||
async markOutliersForSeller() {
|
async markOutliersForSeller() {
|
||||||
return { status: 'UPDATED' };
|
return { status: "UPDATED" };
|
||||||
},
|
},
|
||||||
async calculateForSeller() {
|
async calculateForSeller() {
|
||||||
return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] };
|
return {
|
||||||
|
status: "CALCULATED",
|
||||||
|
valuation: { valueMinor: 2500 },
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const observation = await service.addObservation({
|
const observation = await service.addObservation({
|
||||||
sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1',
|
sellerId: "seller-1",
|
||||||
providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1',
|
productId: "product-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
providerId: "manual",
|
||||||
|
priceMinor: 2500,
|
||||||
|
sourceIdentifier: "source-1",
|
||||||
|
conditionId: "condition-1",
|
||||||
});
|
});
|
||||||
assert.equal(observation.priceMinor, 2500);
|
assert.equal(observation.priceMinor, 2500);
|
||||||
const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' });
|
const valuation = await service.calculate({
|
||||||
|
sellerId: "seller-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
});
|
||||||
assert.equal(valuation.valuation.valueMinor, 2500);
|
assert.equal(valuation.valuation.valueMinor, 2500);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requires a reason for an audited market-value override', async () => {
|
test("requires a reason for an audited market-value override", async () => {
|
||||||
const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } });
|
const service = createPricingService({
|
||||||
|
pricingRepository: {
|
||||||
|
async createOverride() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }),
|
() =>
|
||||||
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400,
|
service.override({
|
||||||
|
sellerId: "seller-1",
|
||||||
|
actorUserId: "user-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
valueMinor: 2000,
|
||||||
|
reason: "x",
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.code === "OVERRIDE_REASON_REQUIRED" && error.statusCode === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails clearly when a requested pricing provider is unavailable', async () => {
|
test("fails clearly when a requested pricing provider is unavailable", async () => {
|
||||||
const service = createPricingService({
|
const service = createPricingService({
|
||||||
pricingRepository: {},
|
pricingRepository: {},
|
||||||
pricingProviderRegistry: { get() { return null; } },
|
pricingProviderRegistry: {
|
||||||
|
get() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }),
|
() =>
|
||||||
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503,
|
service.refresh({
|
||||||
|
sellerId: "seller-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
providerId: "missing",
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.code === "PRICING_PROVIDER_UNAVAILABLE" && error.statusCode === 503,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enqueues a seller-scoped pricing refresh and reads its status', async () => {
|
test("enqueues a seller-scoped pricing refresh and reads its status", async () => {
|
||||||
const service = createPricingService({
|
const service = createPricingService({
|
||||||
pricingRepository: {},
|
pricingRepository: {},
|
||||||
pricingRefreshJobRepository: {
|
pricingRefreshJobRepository: {
|
||||||
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; },
|
async create(input) {
|
||||||
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; },
|
return { id: "job-1", status: "PENDING", ...input };
|
||||||
|
},
|
||||||
|
async findByIdForSeller(id, sellerId) {
|
||||||
|
return { id, sellerId, status: "COMPLETED" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providerMappingRepository: {
|
||||||
|
async findActiveForSeller() {
|
||||||
|
return { providerId: "mapped-provider" };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' });
|
const job = await service.enqueueRefresh({
|
||||||
assert.equal(job.status, 'PENDING');
|
sellerId: "seller-1",
|
||||||
assert.equal(job.providerId, 'mapped-provider');
|
inventoryItemId: "item-1",
|
||||||
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED');
|
providerId: "provider-1",
|
||||||
|
productId: "product-1",
|
||||||
|
conditionId: "condition-1",
|
||||||
|
});
|
||||||
|
assert.equal(job.status, "PENDING");
|
||||||
|
assert.equal(job.providerId, "mapped-provider");
|
||||||
|
assert.equal(
|
||||||
|
(await service.getJob({ sellerId: "seller-1", jobId: job.id })).status,
|
||||||
|
"COMPLETED",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lists and retries seller-scoped failed pricing jobs', async () => {
|
test("lists and retries seller-scoped failed pricing jobs", async () => {
|
||||||
const service = createPricingService({
|
const service = createPricingService({
|
||||||
pricingRepository: {},
|
pricingRepository: {},
|
||||||
pricingRefreshJobRepository: {
|
pricingRefreshJobRepository: {
|
||||||
async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; },
|
async listForSeller() {
|
||||||
async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; },
|
return [{ id: "job-1", status: "FAILED" }];
|
||||||
|
},
|
||||||
|
async retryForSeller() {
|
||||||
|
return { id: "job-1", status: "PENDING" };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1);
|
assert.equal((await service.listJobs({ sellerId: "seller-1" })).length, 1);
|
||||||
assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING');
|
assert.equal(
|
||||||
});
|
(await service.retryJob({ sellerId: "seller-1", jobId: "job-1" })).status,
|
||||||
|
"PENDING",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js';
|
import { createProviderMappingService } from "../../src/modules/pricing/services/provider-mapping-service.js";
|
||||||
|
|
||||||
test('creates and disables seller-owned provider mappings', async () => {
|
test("creates and disables seller-owned provider mappings", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const service = createProviderMappingService({
|
const service = createProviderMappingService({
|
||||||
providerMappingRepository: {
|
providerMappingRepository: {
|
||||||
async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; },
|
async createForSeller(input, sellerId) {
|
||||||
async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; },
|
calls.push(["create", input, sellerId]);
|
||||||
async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; },
|
return { status: "CREATED", mapping: { id: "mapping-1", ...input } };
|
||||||
|
},
|
||||||
|
async listForSeller() {
|
||||||
|
return [{ id: "mapping-1", status: "ACTIVE" }];
|
||||||
|
},
|
||||||
|
async disableForSeller() {
|
||||||
|
return {
|
||||||
|
status: "DISABLED",
|
||||||
|
mapping: { id: "mapping-1", status: "DISABLED" },
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' });
|
const created = await service.create({
|
||||||
assert.equal(created.id, 'mapping-1');
|
sellerId: "seller-1",
|
||||||
assert.equal(calls[0][2], 'seller-1');
|
productId: "product-1",
|
||||||
assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1);
|
providerId: "fixture",
|
||||||
assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED');
|
mappingType: "PRODUCT",
|
||||||
|
externalIdentifier: "ext-1",
|
||||||
|
});
|
||||||
|
assert.equal(created.id, "mapping-1");
|
||||||
|
assert.equal(calls[0][2], "seller-1");
|
||||||
|
assert.equal(
|
||||||
|
(await service.list({ sellerId: "seller-1", productId: "product-1" }))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await service.disable({ sellerId: "seller-1", id: "mapping-1" })).status,
|
||||||
|
"DISABLED",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createSaleService } from '../../src/modules/sales/services/sale-service.js';
|
import { createSaleService } from "../../src/modules/sales/services/sale-service.js";
|
||||||
|
|
||||||
function validInput(overrides = {}) {
|
function validInput(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
inventoryItemId: 'inventory-1',
|
inventoryItemId: "inventory-1",
|
||||||
saleDate: '2026-09-09',
|
saleDate: "2026-09-09",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
costBasisMinor: 1200,
|
costBasisMinor: 1200,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('creates a sale through the repository with validated money values', async () => {
|
test("creates a sale through the repository with validated money values", async () => {
|
||||||
let received;
|
let received;
|
||||||
const service = createSaleService({
|
const service = createSaleService({
|
||||||
saleRepository: {
|
saleRepository: {
|
||||||
async createForInventoryItem(input) {
|
async createForInventoryItem(input) {
|
||||||
received = input;
|
received = input;
|
||||||
return {
|
return {
|
||||||
status: 'CREATED',
|
status: "CREATED",
|
||||||
sale: {
|
sale: {
|
||||||
id: 'sale-1',
|
id: "sale-1",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
platformFeesMinor: 100,
|
platformFeesMinor: 100,
|
||||||
paymentProcessingFeesMinor: 0,
|
paymentProcessingFeesMinor: 0,
|
||||||
@@ -38,9 +38,9 @@ test('creates a sale through the repository with validated money values', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(await service.create(validInput()), {
|
assert.deepEqual(await service.create(validInput()), {
|
||||||
status: 'CREATED',
|
status: "CREATED",
|
||||||
sale: {
|
sale: {
|
||||||
id: 'sale-1',
|
id: "sale-1",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
platformFeesMinor: 100,
|
platformFeesMinor: 100,
|
||||||
paymentProcessingFeesMinor: 0,
|
paymentProcessingFeesMinor: 0,
|
||||||
@@ -52,85 +52,105 @@ test('creates a sale through the repository with validated money values', async
|
|||||||
netProceedsMinor: 2399,
|
netProceedsMinor: 2399,
|
||||||
profitMinor: 1199,
|
profitMinor: 1199,
|
||||||
});
|
});
|
||||||
assert.equal(received.sale.sellerId, 'seller-1');
|
assert.equal(received.sale.sellerId, "seller-1");
|
||||||
assert.equal(received.sale.soldPriceMinor, 2499);
|
assert.equal(received.sale.soldPriceMinor, 2499);
|
||||||
assert.equal(received.costBasisMinor, 1200);
|
assert.equal(received.costBasisMinor, 1200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lists seller sales with derived net proceeds and profit', async () => {
|
test("lists seller sales with derived net proceeds and profit", async () => {
|
||||||
const service = createSaleService({
|
const service = createSaleService({
|
||||||
saleRepository: {
|
saleRepository: {
|
||||||
async listBySellerId(sellerId) {
|
async listBySellerId(sellerId) {
|
||||||
assert.equal(sellerId, 'seller-1');
|
assert.equal(sellerId, "seller-1");
|
||||||
return [{
|
return [
|
||||||
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
{
|
||||||
saleItem: { costBasisMinor: 1500 },
|
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
||||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
saleItem: { costBasisMinor: 1500 },
|
||||||
}];
|
inventoryItem: { id: "inventory-1", status: "SOLD" },
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sale] = await service.list({ sellerId: 'seller-1' });
|
const [sale] = await service.list({ sellerId: "seller-1" });
|
||||||
assert.equal(sale.netProceedsMinor, 2800);
|
assert.equal(sale.netProceedsMinor, 2800);
|
||||||
assert.equal(sale.profitMinor, 1300);
|
assert.equal(sale.profitMinor, 1300);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('voids an owned sale with a required reason', async () => {
|
test("voids an owned sale with a required reason", async () => {
|
||||||
let received;
|
let received;
|
||||||
const service = createSaleService({
|
const service = createSaleService({
|
||||||
saleRepository: {
|
saleRepository: {
|
||||||
async voidForSeller(input) {
|
async voidForSeller(input) {
|
||||||
received = input;
|
received = input;
|
||||||
return {
|
return {
|
||||||
status: 'VOIDED',
|
status: "VOIDED",
|
||||||
sale: { id: 'sale-1', soldPriceMinor: 3000 },
|
sale: { id: "sale-1", soldPriceMinor: 3000 },
|
||||||
saleItem: { costBasisMinor: 1500 },
|
saleItem: { costBasisMinor: 1500 },
|
||||||
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' },
|
inventoryItem: { id: "inventory-1", status: "ACTIVE" },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.void({
|
const result = await service.void({
|
||||||
saleId: 'sale-1',
|
saleId: "sale-1",
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
actorUserId: 'user-1',
|
actorUserId: "user-1",
|
||||||
reason: ' Duplicate sale ',
|
reason: " Duplicate sale ",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(received.reason, 'Duplicate sale');
|
assert.equal(received.reason, "Duplicate sale");
|
||||||
assert.equal(result.inventoryItem.status, 'ACTIVE');
|
assert.equal(result.inventoryItem.status, "ACTIVE");
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }),
|
() =>
|
||||||
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400,
|
service.void({
|
||||||
|
saleId: "sale-1",
|
||||||
|
sellerId: "seller-1",
|
||||||
|
actorUserId: "user-1",
|
||||||
|
reason: "x",
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.code === "SALE_VOID_REASON_REQUIRED" && error.statusCode === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates one sale containing multiple inventory items', async () => {
|
test("creates one sale containing multiple inventory items", async () => {
|
||||||
let received;
|
let received;
|
||||||
const service = createSaleService({
|
const service = createSaleService({
|
||||||
saleRepository: {
|
saleRepository: {
|
||||||
async createForInventoryItems(input) {
|
async createForInventoryItems(input) {
|
||||||
received = input;
|
received = input;
|
||||||
return {
|
return {
|
||||||
status: 'CREATED',
|
status: "CREATED",
|
||||||
sale: { id: 'sale-2', soldPriceMinor: 3000 },
|
sale: { id: "sale-2", soldPriceMinor: 3000 },
|
||||||
saleItems: input.items,
|
saleItems: input.items,
|
||||||
saleItem: input.items[0],
|
saleItem: input.items[0],
|
||||||
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })),
|
inventoryItems: input.items.map(({ inventoryItemId }) => ({
|
||||||
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' },
|
id: inventoryItemId,
|
||||||
|
status: "SOLD",
|
||||||
|
})),
|
||||||
|
inventoryItem: { id: input.items[0].inventoryItemId, status: "SOLD" },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.create({
|
const result = await service.create({
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
saleDate: '2026-09-09',
|
saleDate: "2026-09-09",
|
||||||
items: [
|
items: [
|
||||||
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 },
|
{
|
||||||
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 },
|
inventoryItemId: "inventory-1",
|
||||||
|
soldPriceMinor: 1200,
|
||||||
|
costBasisMinor: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inventoryItemId: "inventory-2",
|
||||||
|
soldPriceMinor: 1800,
|
||||||
|
costBasisMinor: 700,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,18 +159,27 @@ test('creates one sale containing multiple inventory items', async () => {
|
|||||||
assert.equal(result.profitMinor, 1800);
|
assert.equal(result.profitMinor, 1800);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('maps missing and already-sold inventory to application errors', async () => {
|
test("maps missing and already-sold inventory to application errors", async () => {
|
||||||
for (const result of [
|
for (const result of [
|
||||||
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 },
|
{ status: "NOT_FOUND", code: "INVENTORY_NOT_FOUND", statusCode: 404 },
|
||||||
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 },
|
{
|
||||||
|
status: "NOT_ACTIVE",
|
||||||
|
code: "INVENTORY_NOT_AVAILABLE_FOR_SALE",
|
||||||
|
statusCode: 409,
|
||||||
|
},
|
||||||
]) {
|
]) {
|
||||||
const service = createSaleService({
|
const service = createSaleService({
|
||||||
saleRepository: { async createForInventoryItem() { return result; } },
|
saleRepository: {
|
||||||
|
async createForInventoryItem() {
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => service.create(validInput()),
|
() => service.create(validInput()),
|
||||||
(error) => error.code === result.code && error.statusCode === result.statusCode,
|
(error) =>
|
||||||
|
error.code === result.code && error.statusCode === result.statusCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
calculateAcquisitionCostBasis,
|
calculateAcquisitionCostBasis,
|
||||||
createAcquisition,
|
createAcquisition,
|
||||||
} from '../../src/modules/acquisitions/domain/acquisition.js';
|
} from "../../src/modules/acquisitions/domain/acquisition.js";
|
||||||
import {
|
import {
|
||||||
calculateMedianPriceMinor,
|
calculateMedianPriceMinor,
|
||||||
createPricingObservation,
|
createPricingObservation,
|
||||||
identifyOutlierPrices,
|
identifyOutlierPrices,
|
||||||
} from '../../src/modules/pricing/domain/pricing-observation.js';
|
} from "../../src/modules/pricing/domain/pricing-observation.js";
|
||||||
|
|
||||||
test('calculates acquisition cost basis from all cost components', () => {
|
test("calculates acquisition cost basis from all cost components", () => {
|
||||||
const total = calculateAcquisitionCostBasis({
|
const total = calculateAcquisitionCostBasis({
|
||||||
costMinor: 1000,
|
costMinor: 1000,
|
||||||
shippingMinor: 150,
|
shippingMinor: 150,
|
||||||
@@ -23,43 +23,48 @@ test('calculates acquisition cost basis from all cost components', () => {
|
|||||||
assert.equal(total, 1075);
|
assert.equal(total, 1075);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects negative acquisition values and missing identity fields', () => {
|
test("rejects negative acquisition values and missing identity fields", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createAcquisition({
|
() =>
|
||||||
inventoryItemId: 'inventory-item-id',
|
createAcquisition({
|
||||||
method: 'PURCHASE',
|
inventoryItemId: "inventory-item-id",
|
||||||
costMinor: -1,
|
method: "PURCHASE",
|
||||||
}),
|
costMinor: -1,
|
||||||
|
}),
|
||||||
/must be a non-negative integer minor unit/,
|
/must be a non-negative integer minor unit/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createAcquisition({
|
() =>
|
||||||
inventoryItemId: '',
|
createAcquisition({
|
||||||
method: 'PURCHASE',
|
inventoryItemId: "",
|
||||||
costMinor: 1000,
|
method: "PURCHASE",
|
||||||
}),
|
costMinor: 1000,
|
||||||
|
}),
|
||||||
/Inventory item ID is required/,
|
/Inventory item ID is required/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates pricing observations and calculates a median observed price', () => {
|
test("creates pricing observations and calculates a median observed price", () => {
|
||||||
const observation = createPricingObservation({
|
const observation = createPricingObservation({
|
||||||
productId: 'product-id',
|
productId: "product-id",
|
||||||
inventoryItemId: 'inventory-item-id',
|
inventoryItemId: "inventory-item-id",
|
||||||
providerId: 'ebay',
|
providerId: "ebay",
|
||||||
priceMinor: 2499,
|
priceMinor: 2499,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
sourceIdentifier: 'listing-1',
|
sourceIdentifier: "listing-1",
|
||||||
conditionId: 'condition-id',
|
conditionId: "condition-id",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(observation.currency, 'USD');
|
assert.equal(observation.currency, "USD");
|
||||||
assert.equal(observation.priceMinor, 2499);
|
assert.equal(observation.priceMinor, 2499);
|
||||||
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
|
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('identifies extreme pricing observations without discarding normal values', () => {
|
test("identifies extreme pricing observations without discarding normal values", () => {
|
||||||
assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]);
|
assert.deepEqual(
|
||||||
|
[...identifyOutlierPrices([1000, 1050, 1100, 5000])],
|
||||||
|
[5000],
|
||||||
|
);
|
||||||
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
|
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,76 +1,83 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
calculateNetSaleProceeds,
|
calculateNetSaleProceeds,
|
||||||
createSale,
|
createSale,
|
||||||
createSaleItem,
|
createSaleItem,
|
||||||
} from '../../src/modules/sales/domain/sale.js';
|
} from "../../src/modules/sales/domain/sale.js";
|
||||||
|
|
||||||
test('creates a valid sale record and calculates net proceeds after fees', () => {
|
test("creates a valid sale record and calculates net proceeds after fees", () => {
|
||||||
const sale = createSale({
|
const sale = createSale({
|
||||||
sellerId: 'seller-id',
|
sellerId: "seller-id",
|
||||||
saleDate: '2026-09-08',
|
saleDate: "2026-09-08",
|
||||||
saleLocation: 'eBay',
|
saleLocation: "eBay",
|
||||||
soldPriceMinor: 4999,
|
soldPriceMinor: 4999,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
platformFeesMinor: 300,
|
platformFeesMinor: 300,
|
||||||
paymentProcessingFeesMinor: 120,
|
paymentProcessingFeesMinor: 120,
|
||||||
shippingCostMinor: 50,
|
shippingCostMinor: 50,
|
||||||
taxesMinor: 90,
|
taxesMinor: 90,
|
||||||
otherFeesMinor: 40,
|
otherFeesMinor: 40,
|
||||||
buyerName: 'Alex',
|
buyerName: "Alex",
|
||||||
buyerContact: 'alex@example.com',
|
buyerContact: "alex@example.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(sale.soldPriceMinor, 4999);
|
assert.equal(sale.soldPriceMinor, 4999);
|
||||||
assert.equal(sale.currency, 'USD');
|
assert.equal(sale.currency, "USD");
|
||||||
assert.equal(calculateNetSaleProceeds({
|
assert.equal(
|
||||||
soldPriceMinor: 4999,
|
calculateNetSaleProceeds({
|
||||||
platformFeesMinor: 300,
|
soldPriceMinor: 4999,
|
||||||
paymentProcessingFeesMinor: 120,
|
platformFeesMinor: 300,
|
||||||
shippingCostMinor: 50,
|
paymentProcessingFeesMinor: 120,
|
||||||
taxesMinor: 90,
|
shippingCostMinor: 50,
|
||||||
otherFeesMinor: 40,
|
taxesMinor: 90,
|
||||||
}), 4399);
|
otherFeesMinor: 40,
|
||||||
|
}),
|
||||||
|
4399,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid sales and negative fee values', () => {
|
test("rejects invalid sales and negative fee values", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createSale({
|
() =>
|
||||||
sellerId: 'seller-id',
|
createSale({
|
||||||
saleDate: '2026-09-08',
|
sellerId: "seller-id",
|
||||||
soldPriceMinor: 0,
|
saleDate: "2026-09-08",
|
||||||
}),
|
soldPriceMinor: 0,
|
||||||
|
}),
|
||||||
/must be a positive integer minor unit/,
|
/must be a positive integer minor unit/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createSaleItem({
|
() =>
|
||||||
saleId: 'sale-id',
|
createSaleItem({
|
||||||
inventoryItemId: 'inventory-item-id',
|
saleId: "sale-id",
|
||||||
soldPriceMinor: 2000,
|
inventoryItemId: "inventory-item-id",
|
||||||
costBasisMinor: -1,
|
soldPriceMinor: 2000,
|
||||||
}),
|
costBasisMinor: -1,
|
||||||
|
}),
|
||||||
/must be a non-negative integer minor unit/,
|
/must be a non-negative integer minor unit/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createSale({
|
() =>
|
||||||
sellerId: 'seller-id',
|
createSale({
|
||||||
saleDate: '2026-02-30',
|
sellerId: "seller-id",
|
||||||
soldPriceMinor: 2000,
|
saleDate: "2026-02-30",
|
||||||
}),
|
soldPriceMinor: 2000,
|
||||||
|
}),
|
||||||
/must be a valid ISO date/,
|
/must be a valid ISO date/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createSale({
|
() =>
|
||||||
sellerId: 'seller-id',
|
createSale({
|
||||||
saleDate: '2026-09-08',
|
sellerId: "seller-id",
|
||||||
soldPriceMinor: 2000,
|
saleDate: "2026-09-08",
|
||||||
currency: 'US dollars',
|
soldPriceMinor: 2000,
|
||||||
}),
|
currency: "US dollars",
|
||||||
|
}),
|
||||||
/must be a three-letter ISO currency code/,
|
/must be a three-letter ISO currency code/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSearchFilters,
|
buildSearchFilters,
|
||||||
createSearchQuery,
|
createSearchQuery,
|
||||||
normalizeSearchQuery,
|
normalizeSearchQuery,
|
||||||
} from '../../src/modules/search/domain/search-query.js';
|
} from "../../src/modules/search/domain/search-query.js";
|
||||||
|
|
||||||
test('normalizes a public search query and keeps filter values within bounds', () => {
|
test("normalizes a public search query and keeps filter values within bounds", () => {
|
||||||
const query = createSearchQuery({
|
const query = createSearchQuery({
|
||||||
query: ' Charizard ex ',
|
query: " Charizard ex ",
|
||||||
category: 'Cards',
|
category: "Cards",
|
||||||
minPriceMinor: 1000,
|
minPriceMinor: 1000,
|
||||||
maxPriceMinor: 5000,
|
maxPriceMinor: 5000,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -18,8 +18,8 @@ test('normalizes a public search query and keeps filter values within bounds', (
|
|||||||
includeSold: false,
|
includeSold: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(query.text, 'charizard ex');
|
assert.equal(query.text, "charizard ex");
|
||||||
assert.equal(query.category, 'Cards');
|
assert.equal(query.category, "Cards");
|
||||||
assert.equal(query.minPriceMinor, 1000);
|
assert.equal(query.minPriceMinor, 1000);
|
||||||
assert.equal(query.maxPriceMinor, 5000);
|
assert.equal(query.maxPriceMinor, 5000);
|
||||||
assert.equal(query.page, 1);
|
assert.equal(query.page, 1);
|
||||||
@@ -27,25 +27,45 @@ test('normalizes a public search query and keeps filter values within bounds', (
|
|||||||
assert.equal(query.includeSold, false);
|
assert.equal(query.includeSold, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid pagination and price boundaries', () => {
|
test("rejects invalid pagination and price boundaries", () => {
|
||||||
assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/);
|
assert.throws(
|
||||||
assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/);
|
() => createSearchQuery({ query: "abc", page: 0, limit: 20 }),
|
||||||
assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/);
|
/page must be >= 1/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createSearchQuery({
|
||||||
|
query: "abc",
|
||||||
|
minPriceMinor: 5000,
|
||||||
|
maxPriceMinor: 1000,
|
||||||
|
}),
|
||||||
|
/minPriceMinor must be <= maxPriceMinor/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createSearchQuery({ query: "abc", limit: 0 }),
|
||||||
|
/limit must be between 1 and 100/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builds a public query filter set that hides sold items by default', () => {
|
test("builds a public query filter set that hides sold items by default", () => {
|
||||||
const filters = buildSearchFilters({ includeSold: false });
|
const filters = buildSearchFilters({ includeSold: false });
|
||||||
|
|
||||||
assert.equal(filters.status, 'AVAILABLE');
|
assert.equal(filters.status, "AVAILABLE");
|
||||||
assert.equal(filters.includeSold, false);
|
assert.equal(filters.includeSold, false);
|
||||||
assert.deepEqual(filters.sort, ['recently_added']);
|
assert.deepEqual(filters.sort, ["recently_added"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts only the supported public search sort values', () => {
|
test("accepts only the supported public search sort values", () => {
|
||||||
assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low');
|
assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
|
||||||
assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/);
|
assert.throws(
|
||||||
|
() => createSearchQuery({ sort: "random" }),
|
||||||
|
/sort must be recent/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes whitespace and punctuation in free-text search terms', () => {
|
test("normalizes whitespace and punctuation in free-text search terms", () => {
|
||||||
assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo');
|
assert.equal(
|
||||||
|
normalizeSearchQuery(" Pikachu #199 / holo "),
|
||||||
|
"pikachu 199 holo",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||||
return buildApp({
|
return buildApp({
|
||||||
@@ -9,7 +9,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
|||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
acquisitionService: {
|
acquisitionService: {
|
||||||
async create(input) {
|
async create(input) {
|
||||||
return { id: 'acquisition-1', ...input, costBasisMinor: 1075 };
|
return { id: "acquisition-1", ...input, costBasisMinor: 1075 };
|
||||||
},
|
},
|
||||||
async list() {
|
async list() {
|
||||||
return [];
|
return [];
|
||||||
@@ -18,21 +18,23 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('creates an acquisition for the authenticated seller', async (testContext) => {
|
test("creates an acquisition for the authenticated seller", async (testContext) => {
|
||||||
let csrfChecked = false;
|
let csrfChecked = false;
|
||||||
const app = createTestApp({
|
const app = createTestApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => { csrfChecked = true; },
|
authorizeUnsafeRequest: async () => {
|
||||||
|
csrfChecked = true;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/acquisitions',
|
url: "/api/v1/admin/acquisitions",
|
||||||
headers: { 'x-csrf-token': 'csrf-token' },
|
headers: { "x-csrf-token": "csrf-token" },
|
||||||
payload: {
|
payload: {
|
||||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||||
method: 'PURCHASE',
|
method: "PURCHASE",
|
||||||
costMinor: 1000,
|
costMinor: 1000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -42,16 +44,18 @@ test('creates an acquisition for the authenticated seller', async (testContext)
|
|||||||
assert.equal(csrfChecked, true);
|
assert.equal(csrfChecked, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid acquisition requests', async (testContext) => {
|
test("rejects invalid acquisition requests", async (testContext) => {
|
||||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
const app = createTestApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/acquisitions',
|
url: "/api/v1/admin/acquisitions",
|
||||||
payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 },
|
payload: { inventoryItemId: "not-a-uuid", method: "", costMinor: -1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 400);
|
assert.equal(response.statusCode, 400);
|
||||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
assert.equal(response.json().error.code, "VALIDATION_ERROR");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
test('returns a seller-scoped dashboard', async (testContext) => {
|
test("returns a seller-scoped dashboard", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } },
|
dashboardService: {
|
||||||
|
async get({ sellerId }) {
|
||||||
|
assert.equal(sellerId, "seller-1");
|
||||||
|
return {
|
||||||
|
counts: {},
|
||||||
|
financials: {},
|
||||||
|
recentSales: [],
|
||||||
|
recentAcquisitions: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' });
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/admin/dashboard",
|
||||||
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] });
|
assert.deepEqual(response.json().data, {
|
||||||
});
|
counts: {},
|
||||||
|
financials: {},
|
||||||
|
recentSales: [],
|
||||||
|
recentAcquisitions: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
test('accepts a protected pricing observation and valuation request', async (testContext) => {
|
test("accepts a protected pricing observation and valuation request", async (testContext) => {
|
||||||
let csrfChecks = 0;
|
let csrfChecks = 0;
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
authorizeUnsafeRequest: async () => {
|
||||||
|
csrfChecks += 1;
|
||||||
|
},
|
||||||
pricingService: {
|
pricingService: {
|
||||||
async addObservation(input) { return { id: 'observation-1', ...input }; },
|
async addObservation(input) {
|
||||||
async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; },
|
return { id: "observation-1", ...input };
|
||||||
|
},
|
||||||
|
async calculate() {
|
||||||
|
return {
|
||||||
|
valuation: {
|
||||||
|
id: "valuation-1",
|
||||||
|
inventoryItemId: "inventory-1",
|
||||||
|
valueMinor: 2500,
|
||||||
|
currency: "USD",
|
||||||
|
strategy: "MEDIAN",
|
||||||
|
confidence: "LOW",
|
||||||
|
sourceCount: 1,
|
||||||
|
},
|
||||||
|
observations: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const observationResponse = await app.inject({
|
const observationResponse = await app.inject({
|
||||||
method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' },
|
method: "POST",
|
||||||
payload: { productId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958' },
|
url: "/api/v1/admin/pricing/observations",
|
||||||
|
headers: { "x-csrf-token": "csrf" },
|
||||||
|
payload: {
|
||||||
|
productId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||||
|
inventoryItemId: "21eb218b-7a95-4f80-a9e3-e306f7ca1f2e",
|
||||||
|
providerId: "manual",
|
||||||
|
priceMinor: 2500,
|
||||||
|
sourceIdentifier: "source-1",
|
||||||
|
conditionId: "ab8150c7-96c1-4b55-8d69-7d0b77d48958",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const valuationResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation",
|
||||||
});
|
});
|
||||||
const valuationResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation' });
|
|
||||||
|
|
||||||
assert.equal(observationResponse.statusCode, 201);
|
assert.equal(observationResponse.statusCode, 201);
|
||||||
assert.equal(valuationResponse.statusCode, 200);
|
assert.equal(valuationResponse.statusCode, 200);
|
||||||
@@ -27,59 +56,74 @@ test('accepts a protected pricing observation and valuation request', async (tes
|
|||||||
assert.equal(csrfChecks, 2);
|
assert.equal(csrfChecks, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts a CSRF-protected market-value override', async (testContext) => {
|
test("accepts a CSRF-protected market-value override", async (testContext) => {
|
||||||
let received;
|
let received;
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => {},
|
authorizeUnsafeRequest: async () => {},
|
||||||
pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } },
|
pricingService: {
|
||||||
|
async override(input) {
|
||||||
|
received = input;
|
||||||
|
return { id: "override-1", ...input };
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override',
|
url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override",
|
||||||
headers: { 'x-csrf-token': 'csrf' },
|
headers: { "x-csrf-token": "csrf" },
|
||||||
payload: { valueMinor: 2600, reason: 'Verified recent sale' },
|
payload: { valueMinor: 2600, reason: "Verified recent sale" },
|
||||||
});
|
});
|
||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
assert.equal(received.actorUserId, 'user-1');
|
assert.equal(received.actorUserId, "user-1");
|
||||||
assert.equal(response.json().data.valueMinor, 2600);
|
assert.equal(response.json().data.valueMinor, 2600);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => {
|
test("enqueues a pricing refresh and exposes seller-scoped job status", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => {},
|
authorizeUnsafeRequest: async () => {},
|
||||||
pricingService: {
|
pricingService: {
|
||||||
async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; },
|
async enqueueRefresh(input) {
|
||||||
async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; },
|
return { id: "job-1", status: "PENDING", createdAt: input.sellerId };
|
||||||
|
},
|
||||||
|
async getJob(input) {
|
||||||
|
assert.equal(input.sellerId, "seller-1");
|
||||||
|
return { id: input.jobId, status: "COMPLETED", error: null };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/inventory/inventory-1/pricing/refresh',
|
url: "/api/v1/admin/inventory/inventory-1/pricing/refresh",
|
||||||
headers: { 'x-csrf-token': 'csrf' },
|
headers: { "x-csrf-token": "csrf" },
|
||||||
payload: { providerId: 'missing' },
|
payload: { providerId: "missing" },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 202);
|
assert.equal(response.statusCode, 202);
|
||||||
assert.equal(response.json().data.status, 'PENDING');
|
assert.equal(response.json().data.status, "PENDING");
|
||||||
|
|
||||||
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' });
|
const jobResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/admin/jobs/job-1",
|
||||||
|
});
|
||||||
assert.equal(jobResponse.statusCode, 200);
|
assert.equal(jobResponse.statusCode, 200);
|
||||||
assert.equal(jobResponse.json().data.status, 'COMPLETED');
|
assert.equal(jobResponse.json().data.status, "COMPLETED");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns a conflict when refresh has no active product mapping', async (testContext) => {
|
test("returns a conflict when refresh has no active product mapping", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => {},
|
authorizeUnsafeRequest: async () => {},
|
||||||
pricingService: {
|
pricingService: {
|
||||||
async enqueueRefresh() {
|
async enqueueRefresh() {
|
||||||
const error = new Error('No active pricing provider mapping exists for this product.');
|
const error = new Error(
|
||||||
error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND';
|
"No active pricing provider mapping exists for this product.",
|
||||||
|
);
|
||||||
|
error.code = "PRICING_PROVIDER_MAPPING_NOT_FOUND";
|
||||||
error.statusCode = 409;
|
error.statusCode = 409;
|
||||||
throw error;
|
throw error;
|
||||||
},
|
},
|
||||||
@@ -88,30 +132,53 @@ test('returns a conflict when refresh has no active product mapping', async (tes
|
|||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/inventory/item-1/pricing/refresh',
|
url: "/api/v1/admin/inventory/item-1/pricing/refresh",
|
||||||
headers: { 'x-csrf-token': 'csrf' },
|
headers: { "x-csrf-token": "csrf" },
|
||||||
payload: { productId: 'product-1', conditionId: 'condition-1' },
|
payload: { productId: "product-1", conditionId: "condition-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 409);
|
assert.equal(response.statusCode, 409);
|
||||||
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND');
|
assert.equal(
|
||||||
|
response.json().error.code,
|
||||||
|
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lists jobs and retries a failed job with CSRF protection', async (testContext) => {
|
test("lists jobs and retries a failed job with CSRF protection", async (testContext) => {
|
||||||
let csrfChecks = 0;
|
let csrfChecks = 0;
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
authorizeUnsafeRequest: async () => {
|
||||||
|
csrfChecks += 1;
|
||||||
|
},
|
||||||
pricingService: {
|
pricingService: {
|
||||||
async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; },
|
async listJobs() {
|
||||||
async retryJob() { return { id: 'job-1', status: 'PENDING' }; },
|
return [
|
||||||
|
{
|
||||||
|
id: "job-1",
|
||||||
|
status: "FAILED",
|
||||||
|
providerId: "fixture",
|
||||||
|
inventoryItemId: "item-1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async retryJob() {
|
||||||
|
return { id: "job-1", status: "PENDING" };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' });
|
const listResponse = await app.inject({
|
||||||
const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } });
|
method: "GET",
|
||||||
|
url: "/api/v1/admin/jobs",
|
||||||
|
});
|
||||||
|
const retryResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/admin/jobs/job-1/retry",
|
||||||
|
headers: { "x-csrf-token": "csrf" },
|
||||||
|
});
|
||||||
assert.equal(listResponse.statusCode, 200);
|
assert.equal(listResponse.statusCode, 200);
|
||||||
assert.equal(retryResponse.statusCode, 202);
|
assert.equal(retryResponse.statusCode, 202);
|
||||||
assert.equal(csrfChecks, 1);
|
assert.equal(csrfChecks, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => {
|
test("protects provider mapping writes and scopes mappings to the seller", async (testContext) => {
|
||||||
let received;
|
let received;
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => {},
|
authorizeUnsafeRequest: async () => {},
|
||||||
providerMappingService: {
|
providerMappingService: {
|
||||||
async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; },
|
async create(input) {
|
||||||
async list() { return []; },
|
received = input;
|
||||||
async disable() { return { id: 'mapping-1', status: 'DISABLED' }; },
|
return { id: "mapping-1", ...input, status: "ACTIVE" };
|
||||||
|
},
|
||||||
|
async list() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async disable() {
|
||||||
|
return { id: "mapping-1", status: "DISABLED" };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/products/product-1/provider-mappings',
|
url: "/api/v1/admin/products/product-1/provider-mappings",
|
||||||
headers: { 'x-csrf-token': 'csrf' },
|
headers: { "x-csrf-token": "csrf" },
|
||||||
payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' },
|
payload: {
|
||||||
|
providerId: "fixture",
|
||||||
|
mappingType: "PRODUCT",
|
||||||
|
externalIdentifier: "ext-1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
assert.equal(received.sellerId, 'seller-1');
|
assert.equal(received.sellerId, "seller-1");
|
||||||
assert.equal(response.json().data.providerId, 'fixture');
|
assert.equal(response.json().data.providerId, "fixture");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
test('returns public search results with pagination metadata', async (testContext) => {
|
test("returns public search results with pagination metadata", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
searchService: {
|
searchService: {
|
||||||
async searchPublic(filters) {
|
async searchPublic(filters) {
|
||||||
assert.equal(filters.page, 2);
|
assert.equal(filters.page, 2);
|
||||||
assert.equal(filters.limit, 5);
|
assert.equal(filters.limit, 5);
|
||||||
assert.equal(filters.includeSold, true);
|
assert.equal(filters.includeSold, true);
|
||||||
assert.equal(filters.sort, 'price_low');
|
assert.equal(filters.sort, "price_low");
|
||||||
assert.equal(filters.conditionId, 'condition-1');
|
assert.equal(filters.conditionId, "condition-1");
|
||||||
return {
|
return {
|
||||||
items: [{ id: 'listing-1', status: 'CLOSED' }],
|
items: [{ id: "listing-1", status: "CLOSED" }],
|
||||||
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
|
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -22,8 +22,8 @@ test('returns public search results with pagination metadata', async (testContex
|
|||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1',
|
url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
@@ -33,44 +33,71 @@ test('returns public search results with pagination metadata', async (testContex
|
|||||||
total: 6,
|
total: 6,
|
||||||
totalPages: 2,
|
totalPages: 2,
|
||||||
});
|
});
|
||||||
assert.equal(response.json().data.items[0].id, 'listing-1');
|
assert.equal(response.json().data.items[0].id, "listing-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects malformed numeric search filters', async (testContext) => {
|
test("rejects malformed numeric search filters", async (testContext) => {
|
||||||
const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } });
|
const app = buildApp({
|
||||||
|
searchService: {
|
||||||
|
async searchPublic() {
|
||||||
|
throw new Error("should not run");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
url: '/api/v1/public/search?minPriceMinor=12.5',
|
url: "/api/v1/public/search?minPriceMinor=12.5",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 400);
|
assert.equal(response.statusCode, 400);
|
||||||
assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER');
|
assert.equal(response.json().error.code, "INVALID_SEARCH_FILTER");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns active public category options', async (testContext) => {
|
test("returns active public category options", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
catalogLookupRepository: {
|
catalogLookupRepository: {
|
||||||
async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; },
|
async listCategories() {
|
||||||
|
return [{ id: "category-1", name: "Trading Cards" }];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' });
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/public/lookups/categories",
|
||||||
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]);
|
assert.deepEqual(response.json().data, [
|
||||||
|
{ id: "category-1", name: "Trading Cards" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns public condition options without category internals', async (testContext) => {
|
test("returns public condition options without category internals", async (testContext) => {
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
catalogLookupRepository: {
|
catalogLookupRepository: {
|
||||||
async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; },
|
async listConditions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "condition-1",
|
||||||
|
name: "Near Mint",
|
||||||
|
code: "NM",
|
||||||
|
categoryId: "private-category",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' });
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/public/lookups/conditions",
|
||||||
|
});
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]);
|
assert.deepEqual(response.json().data, [
|
||||||
});
|
{ id: "condition-1", name: "Near Mint", code: "NM" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { buildApp } from '../../src/app/build-app.js';
|
import { buildApp } from "../../src/app/build-app.js";
|
||||||
|
|
||||||
const saleResult = {
|
const saleResult = {
|
||||||
sale: {
|
sale: {
|
||||||
id: 'sale-1',
|
id: "sale-1",
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
saleDate: '2026-09-09',
|
saleDate: "2026-09-09",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
platformFeesMinor: 0,
|
platformFeesMinor: 0,
|
||||||
paymentProcessingFeesMinor: 0,
|
paymentProcessingFeesMinor: 0,
|
||||||
shippingCostMinor: 0,
|
shippingCostMinor: 0,
|
||||||
@@ -17,12 +17,12 @@ const saleResult = {
|
|||||||
otherFeesMinor: 0,
|
otherFeesMinor: 0,
|
||||||
},
|
},
|
||||||
saleItem: {
|
saleItem: {
|
||||||
id: 'sale-item-1',
|
id: "sale-item-1",
|
||||||
inventoryItemId: 'inventory-1',
|
inventoryItemId: "inventory-1",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
costBasisMinor: 1200,
|
costBasisMinor: 1200,
|
||||||
},
|
},
|
||||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
inventoryItem: { id: "inventory-1", status: "SOLD" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||||
@@ -31,70 +31,74 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
|||||||
authorizeUnsafeRequest,
|
authorizeUnsafeRequest,
|
||||||
saleService: {
|
saleService: {
|
||||||
async create(input) {
|
async create(input) {
|
||||||
assert.equal(input.sellerId, 'seller-1');
|
assert.equal(input.sellerId, "seller-1");
|
||||||
return saleResult;
|
return saleResult;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => {
|
test("creates a sale for the authenticated seller with CSRF protection", async (testContext) => {
|
||||||
let csrfChecks = 0;
|
let csrfChecks = 0;
|
||||||
const app = createTestApp({
|
const app = createTestApp({
|
||||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
authorizeUnsafeRequest: async () => {
|
||||||
|
csrfChecks += 1;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/sales',
|
url: "/api/v1/admin/sales",
|
||||||
headers: { 'x-csrf-token': 'csrf-token' },
|
headers: { "x-csrf-token": "csrf-token" },
|
||||||
payload: {
|
payload: {
|
||||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||||
costBasisMinor: 1200,
|
costBasisMinor: 1200,
|
||||||
saleDate: '2026-09-09',
|
saleDate: "2026-09-09",
|
||||||
soldPriceMinor: 2499,
|
soldPriceMinor: 2499,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
assert.equal(response.json().data.inventoryItem.status, "SOLD");
|
||||||
assert.equal(csrfChecks, 1);
|
assert.equal(csrfChecks, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => {
|
test("rejects invalid sale requests and unauthenticated requests", async (testContext) => {
|
||||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
const app = createTestApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||||
|
});
|
||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const invalidResponse = await app.inject({
|
const invalidResponse = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/sales',
|
url: "/api/v1/admin/sales",
|
||||||
payload: {
|
payload: {
|
||||||
inventoryItemId: 'not-a-uuid',
|
inventoryItemId: "not-a-uuid",
|
||||||
costBasisMinor: -1,
|
costBasisMinor: -1,
|
||||||
saleDate: 'tomorrow',
|
saleDate: "tomorrow",
|
||||||
soldPriceMinor: 0,
|
soldPriceMinor: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(invalidResponse.statusCode, 400);
|
assert.equal(invalidResponse.statusCode, 400);
|
||||||
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR');
|
assert.equal(invalidResponse.json().error.code, "VALIDATION_ERROR");
|
||||||
|
|
||||||
const unauthenticatedApp = createTestApp();
|
const unauthenticatedApp = createTestApp();
|
||||||
testContext.after(() => unauthenticatedApp.close());
|
testContext.after(() => unauthenticatedApp.close());
|
||||||
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/sales',
|
url: "/api/v1/admin/sales",
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
assert.equal(unauthenticatedResponse.statusCode, 401);
|
assert.equal(unauthenticatedResponse.statusCode, 401);
|
||||||
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED');
|
assert.equal(unauthenticatedResponse.json().error.code, "UNAUTHORIZED");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('voids a sale with CSRF protection and actor identity', async (testContext) => {
|
test("voids a sale with CSRF protection and actor identity", async (testContext) => {
|
||||||
let voidInput;
|
let voidInput;
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||||
authorizeUnsafeRequest: async () => {},
|
authorizeUnsafeRequest: async () => {},
|
||||||
saleService: {
|
saleService: {
|
||||||
async void(input) {
|
async void(input) {
|
||||||
@@ -106,13 +110,13 @@ test('voids a sale with CSRF protection and actor identity', async (testContext)
|
|||||||
testContext.after(() => app.close());
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
url: '/api/v1/admin/sales/sale-1/void',
|
url: "/api/v1/admin/sales/sale-1/void",
|
||||||
headers: { 'x-csrf-token': 'csrf-token' },
|
headers: { "x-csrf-token": "csrf-token" },
|
||||||
payload: { reason: 'Duplicate entry' },
|
payload: { reason: "Duplicate entry" },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
assert.equal(voidInput.actorUserId, 'user-1');
|
assert.equal(voidInput.actorUserId, "user-1");
|
||||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
assert.equal(response.json().data.inventoryItem.status, "SOLD");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test, { after, beforeEach } from 'node:test';
|
import test, { after, beforeEach } from "node:test";
|
||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
import { createDatabase } from "../../src/infrastructure/database/client.js";
|
||||||
import {
|
import {
|
||||||
categories,
|
categories,
|
||||||
conditionProfiles,
|
conditionProfiles,
|
||||||
@@ -13,76 +13,103 @@ import {
|
|||||||
securityAuditEvents,
|
securityAuditEvents,
|
||||||
sellers,
|
sellers,
|
||||||
users,
|
users,
|
||||||
} from '../../src/infrastructure/database/schema.js';
|
} from "../../src/infrastructure/database/schema.js";
|
||||||
import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js';
|
import { createAcquisitionRepository } from "../../src/modules/acquisitions/repositories/acquisition-repository.js";
|
||||||
import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js';
|
import { createPricingRepository } from "../../src/modules/pricing/repositories/pricing-repository.js";
|
||||||
import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js';
|
import { createPricingRefreshJobRepository } from "../../src/modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||||
import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js';
|
import { createProviderMappingRepository } from "../../src/modules/pricing/repositories/provider-mapping-repository.js";
|
||||||
import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js';
|
import { createSaleRepository } from "../../src/modules/sales/repositories/sale-repository.js";
|
||||||
|
|
||||||
const connectionString = process.env.TEST_DATABASE_URL;
|
const connectionString = process.env.TEST_DATABASE_URL;
|
||||||
|
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
throw new Error("TEST_DATABASE_URL is required to run integration tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
if (!new URL(connectionString).pathname.endsWith("_test")) {
|
||||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
throw new Error(
|
||||||
|
'TEST_DATABASE_URL must reference a database whose name ends in "_test".',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const database = createDatabase(connectionString);
|
const database = createDatabase(connectionString);
|
||||||
const acquisitionRepository = createAcquisitionRepository(database.db);
|
const acquisitionRepository = createAcquisitionRepository(database.db);
|
||||||
const pricingRepository = createPricingRepository(database.db);
|
const pricingRepository = createPricingRepository(database.db);
|
||||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||||
|
database.db,
|
||||||
|
);
|
||||||
const providerMappingRepository = createProviderMappingRepository(database.db);
|
const providerMappingRepository = createProviderMappingRepository(database.db);
|
||||||
const saleRepository = createSaleRepository(database.db);
|
const saleRepository = createSaleRepository(database.db);
|
||||||
|
|
||||||
async function createFixture() {
|
async function createFixture() {
|
||||||
const [user] = await database.db.insert(users).values({
|
const [user] = await database.db
|
||||||
email: 'transactions@example.test',
|
.insert(users)
|
||||||
passwordHash: 'not-a-real-password',
|
.values({
|
||||||
}).returning();
|
email: "transactions@example.test",
|
||||||
const [seller] = await database.db.insert(sellers).values({
|
passwordHash: "not-a-real-password",
|
||||||
userId: user.id,
|
})
|
||||||
displayName: 'Transactions Owner',
|
.returning();
|
||||||
}).returning();
|
const [seller] = await database.db
|
||||||
const [category] = await database.db.insert(categories).values({
|
.insert(sellers)
|
||||||
name: 'Transactions Cards',
|
.values({
|
||||||
slug: 'transactions-cards',
|
userId: user.id,
|
||||||
}).returning();
|
displayName: "Transactions Owner",
|
||||||
const [profile] = await database.db.insert(conditionProfiles).values({
|
})
|
||||||
categoryId: category.id,
|
.returning();
|
||||||
name: 'Card',
|
const [category] = await database.db
|
||||||
}).returning();
|
.insert(categories)
|
||||||
const [condition] = await database.db.insert(conditions).values({
|
.values({
|
||||||
conditionProfileId: profile.id,
|
name: "Transactions Cards",
|
||||||
name: 'Near Mint',
|
slug: "transactions-cards",
|
||||||
code: 'NM',
|
})
|
||||||
qualityRank: 1,
|
.returning();
|
||||||
}).returning();
|
const [profile] = await database.db
|
||||||
const [product] = await database.db.insert(products).values({
|
.insert(conditionProfiles)
|
||||||
sellerId: seller.id,
|
.values({
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
name: 'Integration Card',
|
name: "Card",
|
||||||
slug: 'integration-card',
|
})
|
||||||
}).returning();
|
.returning();
|
||||||
const inventory = await database.db.insert(inventoryItems).values([
|
const [condition] = await database.db
|
||||||
{ productId: product.id, conditionId: condition.id },
|
.insert(conditions)
|
||||||
{ productId: product.id, conditionId: condition.id },
|
.values({
|
||||||
]).returning();
|
conditionProfileId: profile.id,
|
||||||
|
name: "Near Mint",
|
||||||
|
code: "NM",
|
||||||
|
qualityRank: 1,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [product] = await database.db
|
||||||
|
.insert(products)
|
||||||
|
.values({
|
||||||
|
sellerId: seller.id,
|
||||||
|
categoryId: category.id,
|
||||||
|
name: "Integration Card",
|
||||||
|
slug: "integration-card",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const inventory = await database.db
|
||||||
|
.insert(inventoryItems)
|
||||||
|
.values([
|
||||||
|
{ productId: product.id, conditionId: condition.id },
|
||||||
|
{ productId: product.id, conditionId: condition.id },
|
||||||
|
])
|
||||||
|
.returning();
|
||||||
|
|
||||||
return { user, seller, product, condition, inventory };
|
return { user, seller, product, condition, inventory };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await database.db.execute(sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`);
|
await database.db.execute(
|
||||||
|
sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await database.close();
|
await database.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => {
|
test("persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally", async () => {
|
||||||
const { user, seller, product, condition, inventory } = await createFixture();
|
const { user, seller, product, condition, inventory } = await createFixture();
|
||||||
const [firstItem, secondItem] = inventory;
|
const [firstItem, secondItem] = inventory;
|
||||||
|
|
||||||
@@ -90,47 +117,50 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
|
|||||||
sellerId: seller.id,
|
sellerId: seller.id,
|
||||||
acquisition: {
|
acquisition: {
|
||||||
inventoryItemId: firstItem.id,
|
inventoryItemId: firstItem.id,
|
||||||
method: 'PURCHASE',
|
method: "PURCHASE",
|
||||||
costMinor: 1000,
|
costMinor: 1000,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
shippingMinor: 100,
|
shippingMinor: 100,
|
||||||
taxMinor: 50,
|
taxMinor: 50,
|
||||||
otherFeesMinor: 0,
|
otherFeesMinor: 0,
|
||||||
estimatedTradeValueMinor: 0,
|
estimatedTradeValueMinor: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(acquisition.status, 'CREATED');
|
assert.equal(acquisition.status, "CREATED");
|
||||||
|
|
||||||
for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) {
|
for (const [item, price] of [
|
||||||
|
[firstItem, 2500],
|
||||||
|
[secondItem, 3000],
|
||||||
|
]) {
|
||||||
const observation = await pricingRepository.addObservationForSeller({
|
const observation = await pricingRepository.addObservationForSeller({
|
||||||
sellerId: seller.id,
|
sellerId: seller.id,
|
||||||
observation: {
|
observation: {
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
inventoryItemId: item.id,
|
inventoryItemId: item.id,
|
||||||
providerId: 'integration',
|
providerId: "integration",
|
||||||
priceMinor: price,
|
priceMinor: price,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
sourceIdentifier: `source-${item.id}`,
|
sourceIdentifier: `source-${item.id}`,
|
||||||
conditionId: condition.id,
|
conditionId: condition.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(observation.status, 'CREATED');
|
assert.equal(observation.status, "CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
const valuation = await pricingRepository.calculateForSeller({
|
const valuation = await pricingRepository.calculateForSeller({
|
||||||
sellerId: seller.id,
|
sellerId: seller.id,
|
||||||
inventoryItemId: firstItem.id,
|
inventoryItemId: firstItem.id,
|
||||||
});
|
});
|
||||||
assert.equal(valuation.status, 'CALCULATED');
|
assert.equal(valuation.status, "CALCULATED");
|
||||||
assert.equal(valuation.valuation.valueMinor, 2500);
|
assert.equal(valuation.valuation.valueMinor, 2500);
|
||||||
assert.equal(valuation.valuation.sourceCount, 1);
|
assert.equal(valuation.valuation.sourceCount, 1);
|
||||||
|
|
||||||
const sale = await saleRepository.createForInventoryItems({
|
const sale = await saleRepository.createForInventoryItems({
|
||||||
sale: {
|
sale: {
|
||||||
sellerId: seller.id,
|
sellerId: seller.id,
|
||||||
saleDate: '2026-09-09',
|
saleDate: "2026-09-09",
|
||||||
soldPriceMinor: 5500,
|
soldPriceMinor: 5500,
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
platformFeesMinor: 200,
|
platformFeesMinor: 200,
|
||||||
paymentProcessingFeesMinor: 0,
|
paymentProcessingFeesMinor: 0,
|
||||||
shippingCostMinor: 0,
|
shippingCostMinor: 0,
|
||||||
@@ -138,58 +168,92 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
|
|||||||
otherFeesMinor: 0,
|
otherFeesMinor: 0,
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
{ inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 },
|
{
|
||||||
{ inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 },
|
inventoryItemId: firstItem.id,
|
||||||
|
soldPriceMinor: 2500,
|
||||||
|
costBasisMinor: 1150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inventoryItemId: secondItem.id,
|
||||||
|
soldPriceMinor: 3000,
|
||||||
|
costBasisMinor: 900,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
assert.equal(sale.status, 'CREATED');
|
assert.equal(sale.status, "CREATED");
|
||||||
assert.equal(sale.saleItems.length, 2);
|
assert.equal(sale.saleItems.length, 2);
|
||||||
assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']);
|
assert.deepEqual(
|
||||||
|
sale.inventoryItems.map(({ status }) => status),
|
||||||
|
["SOLD", "SOLD"],
|
||||||
|
);
|
||||||
|
|
||||||
const voided = await saleRepository.voidForSeller({
|
const voided = await saleRepository.voidForSeller({
|
||||||
saleId: sale.sale.id,
|
saleId: sale.sale.id,
|
||||||
sellerId: seller.id,
|
sellerId: seller.id,
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
reason: 'Integration correction',
|
reason: "Integration correction",
|
||||||
});
|
});
|
||||||
assert.equal(voided.status, 'VOIDED');
|
assert.equal(voided.status, "VOIDED");
|
||||||
assert.equal(voided.sale.status, 'VOIDED');
|
assert.equal(voided.sale.status, "VOIDED");
|
||||||
assert.equal(voided.saleItems.length, 2);
|
assert.equal(voided.saleItems.length, 2);
|
||||||
|
|
||||||
const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`);
|
const [auditEvent] = await database.db
|
||||||
|
.select()
|
||||||
|
.from(securityAuditEvents)
|
||||||
|
.where(sql`event = 'SALE_VOIDED'`);
|
||||||
assert.equal(auditEvent.userId, user.id);
|
assert.equal(auditEvent.userId, user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('claims and completes a durable pricing refresh job', async () => {
|
test("claims and completes a durable pricing refresh job", async () => {
|
||||||
const { user, seller, product, condition, inventory } = await createFixture();
|
const { user, seller, product, condition, inventory } = await createFixture();
|
||||||
const [job] = await pricingRefreshJobRepository.create({
|
const [job] = await pricingRefreshJobRepository
|
||||||
sellerId: seller.id,
|
.create({
|
||||||
inventoryItemId: inventory[0].id,
|
sellerId: seller.id,
|
||||||
productId: product.id,
|
inventoryItemId: inventory[0].id,
|
||||||
conditionId: condition.id,
|
productId: product.id,
|
||||||
providerId: 'integration',
|
conditionId: condition.id,
|
||||||
}).then((created) => [created]);
|
providerId: "integration",
|
||||||
|
})
|
||||||
|
.then((created) => [created]);
|
||||||
|
|
||||||
const claimed = await pricingRefreshJobRepository.claimNext();
|
const claimed = await pricingRefreshJobRepository.claimNext();
|
||||||
assert.equal(claimed.id, job.id);
|
assert.equal(claimed.id, job.id);
|
||||||
assert.equal(claimed.status, 'PROCESSING');
|
assert.equal(claimed.status, "PROCESSING");
|
||||||
const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 });
|
const completed = await pricingRefreshJobRepository.complete(
|
||||||
assert.equal(completed.status, 'COMPLETED');
|
job.id,
|
||||||
|
[{ priceMinor: 2500 }],
|
||||||
|
{ valueMinor: 2500 },
|
||||||
|
);
|
||||||
|
assert.equal(completed.status, "COMPLETED");
|
||||||
assert.deepEqual(completed.valuation, { valueMinor: 2500 });
|
assert.deepEqual(completed.valuation, { valueMinor: 2500 });
|
||||||
assert.equal(user.email, 'transactions@example.test');
|
assert.equal(user.email, "transactions@example.test");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persists and disables seller-owned provider mappings', async () => {
|
test("persists and disables seller-owned provider mappings", async () => {
|
||||||
const { seller, product } = await createFixture();
|
const { seller, product } = await createFixture();
|
||||||
const created = await providerMappingRepository.createForSeller({
|
const created = await providerMappingRepository.createForSeller(
|
||||||
productId: product.id,
|
{
|
||||||
providerId: 'integration',
|
productId: product.id,
|
||||||
mappingType: 'PRODUCT',
|
providerId: "integration",
|
||||||
externalIdentifier: 'catalog-1',
|
mappingType: "PRODUCT",
|
||||||
}, seller.id);
|
externalIdentifier: "catalog-1",
|
||||||
assert.equal(created.status, 'CREATED');
|
},
|
||||||
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1);
|
seller.id,
|
||||||
const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id);
|
);
|
||||||
assert.equal(disabled.status, 'DISABLED');
|
assert.equal(created.status, "CREATED");
|
||||||
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0);
|
assert.equal(
|
||||||
|
(await providerMappingRepository.listForSeller(product.id, seller.id))
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const disabled = await providerMappingRepository.disableForSeller(
|
||||||
|
created.mapping.id,
|
||||||
|
seller.id,
|
||||||
|
);
|
||||||
|
assert.equal(disabled.status, "DISABLED");
|
||||||
|
assert.equal(
|
||||||
|
(await providerMappingRepository.listForSeller(product.id, seller.id))
|
||||||
|
.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import ImageGallery from './ImageGallery.vue';
|
import ImageGallery from "./ImageGallery.vue";
|
||||||
|
|
||||||
export default { title: 'Molecules/ImageGallery', component: ImageGallery };
|
export default { title: "Molecules/ImageGallery", component: ImageGallery };
|
||||||
export const ContainedMuseumStage = {
|
export const ContainedMuseumStage = {
|
||||||
args: {
|
args: {
|
||||||
images: [
|
images: [
|
||||||
{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' },
|
{ id: "front", label: "FRONT", alt: "Front of Charizard ex" },
|
||||||
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
|
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -13,21 +13,37 @@ export const ContainedMuseumStage = {
|
|||||||
export const WithPublicImageUrls = {
|
export const WithPublicImageUrls = {
|
||||||
args: {
|
args: {
|
||||||
images: [
|
images: [
|
||||||
{ id: 'front', src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', alt: 'Front of Charizard ex' },
|
{
|
||||||
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
|
id: "front",
|
||||||
|
src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E',
|
||||||
|
alt: "Front of Charizard ex",
|
||||||
|
},
|
||||||
|
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BrokenImageFallback = {
|
export const BrokenImageFallback = {
|
||||||
args: {
|
args: {
|
||||||
images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }],
|
images: [
|
||||||
|
{
|
||||||
|
id: "broken",
|
||||||
|
src: "/fixtures/missing-image.webp",
|
||||||
|
alt: "Unavailable collectible image",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoadingImage = {
|
export const LoadingImage = {
|
||||||
args: {
|
args: {
|
||||||
images: [{ id: 'loading', src: '/fixtures/slow-image.webp', alt: 'Collectible image loading' }],
|
images: [
|
||||||
|
{
|
||||||
|
id: "loading",
|
||||||
|
src: "/fixtures/slow-image.webp",
|
||||||
|
alt: "Collectible image loading",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
parameters: { chromatic: { delay: 1000 } },
|
parameters: { chromatic: { delay: 1000 } },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
images: { type: Array, required: true },
|
images: { type: Array, required: true },
|
||||||
@@ -9,7 +9,7 @@ const failedImages = ref(new Set());
|
|||||||
const loadingImages = ref(new Set());
|
const loadingImages = ref(new Set());
|
||||||
|
|
||||||
function imageKey(image) {
|
function imageKey(image) {
|
||||||
return image?.id ?? '';
|
return image?.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function markLoading(image) {
|
function markLoading(image) {
|
||||||
@@ -36,12 +36,67 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
|||||||
<template>
|
<template>
|
||||||
<div class="image-gallery">
|
<div class="image-gallery">
|
||||||
<div class="image-gallery__stage">
|
<div class="image-gallery__stage">
|
||||||
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @load="markLoaded(props.images[selectedIndex].id)" @error="markFailed(props.images[selectedIndex].id)" />
|
<img
|
||||||
<span v-if="loadingImages.has(imageKey(props.images[selectedIndex]))" class="image-gallery__placeholder" role="status" aria-label="Loading collectible image">Loading image...</span>
|
v-if="
|
||||||
<span v-else-if="!((props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id))" class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
|
(props.images[selectedIndex]?.src ??
|
||||||
|
props.images[selectedIndex]?.url) &&
|
||||||
|
!failedImages.has(props.images[selectedIndex].id)
|
||||||
|
"
|
||||||
|
:src="
|
||||||
|
props.images[selectedIndex].src ?? props.images[selectedIndex].url
|
||||||
|
"
|
||||||
|
:alt="
|
||||||
|
props.images[selectedIndex].altText ?? props.images[selectedIndex].alt
|
||||||
|
"
|
||||||
|
@load="markLoaded(props.images[selectedIndex].id)"
|
||||||
|
@error="markFailed(props.images[selectedIndex].id)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="loadingImages.has(imageKey(props.images[selectedIndex]))"
|
||||||
|
class="image-gallery__placeholder"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading collectible image"
|
||||||
|
>Loading image...</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="
|
||||||
|
!(
|
||||||
|
(props.images[selectedIndex]?.src ??
|
||||||
|
props.images[selectedIndex]?.url) &&
|
||||||
|
!failedImages.has(props.images[selectedIndex].id)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="image-gallery__placeholder"
|
||||||
|
role="img"
|
||||||
|
:aria-label="
|
||||||
|
props.images[selectedIndex]?.src
|
||||||
|
? 'Collectible image unavailable'
|
||||||
|
: 'No collectible image available'
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
props.images[selectedIndex]?.src
|
||||||
|
? "IMAGE UNAVAILABLE"
|
||||||
|
: (props.images[selectedIndex]?.label ?? "IMAGE")
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">
|
<div
|
||||||
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" :aria-current="index === selectedIndex ? 'true' : undefined" @click="selectedIndex = index; markLoading(image)">
|
v-if="props.images.length > 1"
|
||||||
|
class="image-gallery__controls"
|
||||||
|
aria-label="Gallery images"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in props.images"
|
||||||
|
:key="image.id"
|
||||||
|
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Show image ${index + 1}: ${image.alt}`"
|
||||||
|
:aria-current="index === selectedIndex ? 'true' : undefined"
|
||||||
|
@click="
|
||||||
|
selectedIndex = index;
|
||||||
|
markLoading(image);
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ index + 1 }}
|
{{ index + 1 }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,5 +104,5 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use './ImageGallery.scss';
|
@use "./ImageGallery.scss";
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
import CollectibleDetail from './CollectibleDetail.vue';
|
import CollectibleDetail from "./CollectibleDetail.vue";
|
||||||
|
|
||||||
export default { title: 'Organisms/CollectibleDetail', component: CollectibleDetail };
|
export default {
|
||||||
|
title: "Organisms/CollectibleDetail",
|
||||||
|
component: CollectibleDetail,
|
||||||
|
};
|
||||||
|
|
||||||
export const ListingDetail = {
|
export const ListingDetail = {
|
||||||
args: {
|
args: {
|
||||||
title: 'Charizard ex #199/165',
|
title: "Charizard ex #199/165",
|
||||||
status: 'AVAILABLE',
|
status: "AVAILABLE",
|
||||||
priceMinor: 11500,
|
priceMinor: 11500,
|
||||||
marketValueMinor: 12000,
|
marketValueMinor: 12000,
|
||||||
condition: 'Near Mint',
|
condition: "Near Mint",
|
||||||
description: 'A clean collector copy with a clear front and back.',
|
description: "A clean collector copy with a clear front and back.",
|
||||||
facts: [
|
facts: [
|
||||||
{ label: 'Set', value: 'Scarlet & Violet 151' },
|
{ label: "Set", value: "Scarlet & Violet 151" },
|
||||||
{ label: 'Card number', value: '199/165' },
|
{ label: "Card number", value: "199/165" },
|
||||||
{ label: 'Market context', value: '$120 reference' },
|
{ label: "Market context", value: "$120 reference" },
|
||||||
{ label: 'Updated', value: 'Today' },
|
{ label: "Updated", value: "Today" },
|
||||||
],
|
],
|
||||||
images: [
|
images: [
|
||||||
{ id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' },
|
{ id: "front", label: "FRONT", alt: "Front of Charizard ex" },
|
||||||
{ id: 'back', label: 'BACK', alt: 'Back of Charizard ex' },
|
{ id: "back", label: "BACK", alt: "Back of Charizard ex" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProductWithoutImages = {
|
export const ProductWithoutImages = {
|
||||||
args: {
|
args: {
|
||||||
title: 'Pikachu World Collection',
|
title: "Pikachu World Collection",
|
||||||
description: 'A representative product identity with images still to be added.',
|
description:
|
||||||
facts: [{ label: 'Category', value: 'Collectible merchandise' }],
|
"A representative product identity with images still to be added.",
|
||||||
|
facts: [{ label: "Category", value: "Collectible merchandise" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
|
import ConditionBadge from "@/components/atoms/ConditionBadge.vue";
|
||||||
import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
|
import PriceDisplay from "@/components/atoms/PriceDisplay.vue";
|
||||||
import StatusBadge from '@/components/atoms/StatusBadge.vue';
|
import StatusBadge from "@/components/atoms/StatusBadge.vue";
|
||||||
import ImageGallery from '@/components/molecules/ImageGallery.vue';
|
import ImageGallery from "@/components/molecules/ImageGallery.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
status: { type: String, default: '' },
|
status: { type: String, default: "" },
|
||||||
priceMinor: { type: Number, default: null },
|
priceMinor: { type: Number, default: null },
|
||||||
marketValueMinor: { type: Number, default: null },
|
marketValueMinor: { type: Number, default: null },
|
||||||
marketValueCurrency: { type: String, default: 'USD' },
|
marketValueCurrency: { type: String, default: "USD" },
|
||||||
condition: { type: String, default: '' },
|
condition: { type: String, default: "" },
|
||||||
description: { type: String, default: '' },
|
description: { type: String, default: "" },
|
||||||
facts: { type: Array, default: () => [] },
|
facts: { type: Array, default: () => [] },
|
||||||
images: { type: Array, default: () => [] },
|
images: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,11 @@ defineProps({
|
|||||||
<p v-if="description" class="detail-notes">{{ description }}</p>
|
<p v-if="description" class="detail-notes">{{ description }}</p>
|
||||||
<div class="collectible-detail__summary">
|
<div class="collectible-detail__summary">
|
||||||
<PriceDisplay v-if="priceMinor !== null" :amount-minor="priceMinor" />
|
<PriceDisplay v-if="priceMinor !== null" :amount-minor="priceMinor" />
|
||||||
<PriceDisplay v-if="marketValueMinor !== null" label="Market value" :amount-minor="marketValueMinor" />
|
<PriceDisplay
|
||||||
|
v-if="marketValueMinor !== null"
|
||||||
|
label="Market value"
|
||||||
|
:amount-minor="marketValueMinor"
|
||||||
|
/>
|
||||||
<ConditionBadge v-if="condition" :label="condition" />
|
<ConditionBadge v-if="condition" :label="condition" />
|
||||||
</div>
|
</div>
|
||||||
<dl v-if="facts.length" class="product-facts">
|
<dl v-if="facts.length" class="product-facts">
|
||||||
@@ -43,9 +47,33 @@ defineProps({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.collectible-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-12); align-items: start; }
|
.collectible-detail {
|
||||||
.collectible-detail__identity { display: grid; gap: var(--space-4); padding-top: var(--space-6); }
|
display: grid;
|
||||||
.collectible-detail__identity h1 { margin: 0; }
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
.collectible-detail__summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
gap: var(--space-12);
|
||||||
@media (max-width: 768px) { .collectible-detail { grid-template-columns: 1fr; gap: var(--space-6); } .collectible-detail__identity { padding-top: 0; } }
|
align-items: start;
|
||||||
</style>
|
}
|
||||||
|
.collectible-detail__identity {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-top: var(--space-6);
|
||||||
|
}
|
||||||
|
.collectible-detail__identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.collectible-detail__summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.collectible-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
.collectible-detail__identity {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
import { searchCatalog } from '@/services/catalog-service.js';
|
import { searchCatalog } from "@/services/catalog-service.js";
|
||||||
|
|
||||||
export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialConditionId = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) {
|
export function useCatalogSearch({
|
||||||
|
initialQuery = "",
|
||||||
|
initialCategory = "",
|
||||||
|
initialConditionId = "",
|
||||||
|
initialMinPriceMinor,
|
||||||
|
initialMaxPriceMinor,
|
||||||
|
initialSort = "recent",
|
||||||
|
initialPage = 1,
|
||||||
|
} = {}) {
|
||||||
const query = ref(initialQuery);
|
const query = ref(initialQuery);
|
||||||
const category = ref(initialCategory);
|
const category = ref(initialCategory);
|
||||||
const conditionId = ref(initialConditionId);
|
const conditionId = ref(initialConditionId);
|
||||||
@@ -10,7 +18,10 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
|
|||||||
const maxPriceMinor = ref(initialMaxPriceMinor);
|
const maxPriceMinor = ref(initialMaxPriceMinor);
|
||||||
const sort = ref(initialSort);
|
const sort = ref(initialSort);
|
||||||
const page = ref(initialPage);
|
const page = ref(initialPage);
|
||||||
const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } });
|
const result = ref({
|
||||||
|
items: [],
|
||||||
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||||
|
});
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
let requestId = 0;
|
let requestId = 0;
|
||||||
@@ -21,7 +32,15 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextResult = await searchCatalog({ query: query.value, category: category.value, conditionId: conditionId.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value });
|
const nextResult = await searchCatalog({
|
||||||
|
query: query.value,
|
||||||
|
category: category.value,
|
||||||
|
conditionId: conditionId.value,
|
||||||
|
minPriceMinor: minPriceMinor.value,
|
||||||
|
maxPriceMinor: maxPriceMinor.value,
|
||||||
|
sort: sort.value,
|
||||||
|
page: page.value,
|
||||||
|
});
|
||||||
if (currentRequestId === requestId) result.value = nextResult;
|
if (currentRequestId === requestId) result.value = nextResult;
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (currentRequestId === requestId) error.value = requestError;
|
if (currentRequestId === requestId) error.value = requestError;
|
||||||
@@ -30,10 +49,13 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort], () => {
|
watch(
|
||||||
if (page.value !== 1) page.value = 1;
|
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort],
|
||||||
else load();
|
() => {
|
||||||
});
|
if (page.value !== 1) page.value = 1;
|
||||||
|
else load();
|
||||||
|
},
|
||||||
|
);
|
||||||
watch(page, load);
|
watch(page, load);
|
||||||
|
|
||||||
function goToPage(nextPage) {
|
function goToPage(nextPage) {
|
||||||
@@ -42,5 +64,18 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage };
|
return {
|
||||||
}
|
query,
|
||||||
|
category,
|
||||||
|
conditionId,
|
||||||
|
minPriceMinor,
|
||||||
|
maxPriceMinor,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
goToPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import AdminHomePage from './AdminHomePage.vue';
|
import AdminHomePage from "./AdminHomePage.vue";
|
||||||
|
|
||||||
export default { title: 'Pages/AdminHomePage', component: AdminHomePage };
|
export default { title: "Pages/AdminHomePage", component: AdminHomePage };
|
||||||
export const Overview = {};
|
export const Overview = {};
|
||||||
|
|
||||||
export const ProviderMappingWorkflow = {
|
export const ProviderMappingWorkflow = {
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story: 'The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.',
|
story:
|
||||||
},
|
"The admin workspace includes Product-level provider mapping controls alongside inventory, acquisition, pricing, listing, and sales workflows.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,84 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import { useCatalogSearch } from '@/composables/useCatalogSearch.js';
|
import { useCatalogSearch } from "@/composables/useCatalogSearch.js";
|
||||||
import { listPublicCategories, listPublicConditions } from '@/services/catalog-service.js';
|
import {
|
||||||
import ListingCard from '@/components/molecules/ListingCard.vue';
|
listPublicCategories,
|
||||||
|
listPublicConditions,
|
||||||
|
} from "@/services/catalog-service.js";
|
||||||
|
import ListingCard from "@/components/molecules/ListingCard.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const conditions = ref([]);
|
const conditions = ref([]);
|
||||||
const { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage } = useCatalogSearch({
|
const {
|
||||||
initialQuery: typeof route.query.q === 'string' ? route.query.q : '',
|
query,
|
||||||
initialCategory: typeof route.query.category === 'string' ? route.query.category : '',
|
category,
|
||||||
initialConditionId: typeof route.query.conditionId === 'string' ? route.query.conditionId : '',
|
conditionId,
|
||||||
|
minPriceMinor,
|
||||||
|
maxPriceMinor,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
goToPage,
|
||||||
|
} = useCatalogSearch({
|
||||||
|
initialQuery: typeof route.query.q === "string" ? route.query.q : "",
|
||||||
|
initialCategory:
|
||||||
|
typeof route.query.category === "string" ? route.query.category : "",
|
||||||
|
initialConditionId:
|
||||||
|
typeof route.query.conditionId === "string" ? route.query.conditionId : "",
|
||||||
initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined,
|
initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined,
|
||||||
initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined,
|
initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined,
|
||||||
initialSort: typeof route.query.sort === 'string' ? route.query.sort : 'recent',
|
initialSort:
|
||||||
|
typeof route.query.sort === "string" ? route.query.sort : "recent",
|
||||||
initialPage: Number.parseInt(route.query.page, 10) || 1,
|
initialPage: Number.parseInt(route.query.page, 10) || 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
load(),
|
load(),
|
||||||
listPublicCategories().then((items) => { categories.value = items; }).catch(() => {}),
|
listPublicCategories()
|
||||||
listPublicConditions().then((items) => { conditions.value = items; }).catch(() => {}),
|
.then((items) => {
|
||||||
|
categories.value = items;
|
||||||
|
})
|
||||||
|
.catch(() => {}),
|
||||||
|
listPublicConditions()
|
||||||
|
.then((items) => {
|
||||||
|
conditions.value = items;
|
||||||
|
})
|
||||||
|
.catch(() => {}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery, nextCategory, nextCondition, nextMin, nextMax, nextSort, nextPage]) => {
|
watch(
|
||||||
router.replace({
|
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
|
||||||
query: {
|
([
|
||||||
...(nextQuery ? { q: nextQuery } : {}),
|
nextQuery,
|
||||||
...(nextCategory ? { category: nextCategory } : {}),
|
nextCategory,
|
||||||
...(nextCondition ? { conditionId: nextCondition } : {}),
|
nextCondition,
|
||||||
...(typeof nextMin === 'number' ? { min: String(nextMin) } : {}),
|
nextMin,
|
||||||
...(typeof nextMax === 'number' ? { max: String(nextMax) } : {}),
|
nextMax,
|
||||||
...(nextSort !== 'recent' ? { sort: nextSort } : {}),
|
nextSort,
|
||||||
...(nextPage > 1 ? { page: String(nextPage) } : {}),
|
nextPage,
|
||||||
},
|
]) => {
|
||||||
});
|
router.replace({
|
||||||
});
|
query: {
|
||||||
|
...(nextQuery ? { q: nextQuery } : {}),
|
||||||
|
...(nextCategory ? { category: nextCategory } : {}),
|
||||||
|
...(nextCondition ? { conditionId: nextCondition } : {}),
|
||||||
|
...(typeof nextMin === "number" ? { min: String(nextMin) } : {}),
|
||||||
|
...(typeof nextMax === "number" ? { max: String(nextMax) } : {}),
|
||||||
|
...(nextSort !== "recent" ? { sort: nextSort } : {}),
|
||||||
|
...(nextPage > 1 ? { page: String(nextPage) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,17 +86,52 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
|
|||||||
<header class="catalog-header">
|
<header class="catalog-header">
|
||||||
<p class="catalog-kicker">Pokemon inventory</p>
|
<p class="catalog-kicker">Pokemon inventory</p>
|
||||||
<h1>Find the next favorite.</h1>
|
<h1>Find the next favorite.</h1>
|
||||||
<p class="catalog-intro">Browse available cards, collectibles, and carefully cataloged pieces.</p>
|
<p class="catalog-intro">
|
||||||
|
Browse available cards, collectibles, and carefully cataloged pieces.
|
||||||
|
</p>
|
||||||
<label class="search-field">
|
<label class="search-field">
|
||||||
<span>Search the catalog</span>
|
<span>Search the catalog</span>
|
||||||
<input v-model="query" type="search" placeholder="Try Charizard, 151, plush..." />
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
placeholder="Try Charizard, 151, plush..."
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="catalog-filters">
|
<div class="catalog-filters">
|
||||||
<label>Category <select v-model="category"><option value="">All categories</option><option v-for="item in categories" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
|
<label
|
||||||
<label>Condition <select v-model="conditionId"><option value="">All conditions</option><option v-for="item in conditions" :key="item.id" :value="item.id">{{ item.name }} ({{ item.code }})</option></select></label>
|
>Category
|
||||||
<label>Min price (cents) <input v-model.number="minPriceMinor" type="number" min="0" /></label>
|
<select v-model="category">
|
||||||
<label>Max price (cents) <input v-model.number="maxPriceMinor" type="number" min="0" /></label>
|
<option value="">All categories</option>
|
||||||
<label>Sort <select v-model="sort"><option value="recent">Recently added</option><option value="price_low">Price: low to high</option><option value="price_high">Price: high to low</option></select></label>
|
<option v-for="item in categories" :key="item.id" :value="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Condition
|
||||||
|
<select v-model="conditionId">
|
||||||
|
<option value="">All conditions</option>
|
||||||
|
<option v-for="item in conditions" :key="item.id" :value="item.id">
|
||||||
|
{{ item.name }} ({{ item.code }})
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Min price (cents)
|
||||||
|
<input v-model.number="minPriceMinor" type="number" min="0"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Max price (cents)
|
||||||
|
<input v-model.number="maxPriceMinor" type="number" min="0"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Sort
|
||||||
|
<select v-model="sort">
|
||||||
|
<option value="recent">Recently added</option>
|
||||||
|
<option value="price_low">Price: low to high</option>
|
||||||
|
<option value="price_high">Price: high to low</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -69,13 +142,25 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="isLoading" class="catalog-state">Searching the catalog...</p>
|
<p v-if="isLoading" class="catalog-state">Searching the catalog...</p>
|
||||||
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
|
<p v-else-if="error" class="catalog-state catalog-state--error">
|
||||||
<p v-else-if="result.items.length === 0" class="catalog-state">No relevant matches found.</p>
|
{{ error.message }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="result.items.length === 0" class="catalog-state">
|
||||||
|
No relevant matches found.
|
||||||
|
</p>
|
||||||
<div v-else class="listing-grid">
|
<div v-else class="listing-grid">
|
||||||
<ListingCard v-for="item in result.items" :key="item.id" :listing="item" />
|
<ListingCard
|
||||||
|
v-for="item in result.items"
|
||||||
|
:key="item.id"
|
||||||
|
:listing="item"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav v-if="result.pagination.totalPages > 1" class="pagination" aria-label="Catalog pages">
|
<nav
|
||||||
|
v-if="result.pagination.totalPages > 1"
|
||||||
|
class="pagination"
|
||||||
|
aria-label="Catalog pages"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="page <= 1 || isLoading"
|
:disabled="page <= 1 || isLoading"
|
||||||
@@ -83,7 +168,9 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
|
|||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<span aria-live="polite">Page {{ page }} of {{ result.pagination.totalPages }}</span>
|
<span aria-live="polite"
|
||||||
|
>Page {{ page }} of {{ result.pagination.totalPages }}</span
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="page >= result.pagination.totalPages || isLoading"
|
:disabled="page >= result.pagination.totalPages || isLoading"
|
||||||
@@ -94,4 +181,4 @@ watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page],
|
|||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from "vue";
|
||||||
import { RouterLink, useRoute } from 'vue-router';
|
import { RouterLink, useRoute } from "vue-router";
|
||||||
|
|
||||||
import { getPublicListing } from '@/services/catalog-service.js';
|
import { getPublicListing } from "@/services/catalog-service.js";
|
||||||
import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue';
|
import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const listing = ref(null);
|
const listing = ref(null);
|
||||||
@@ -23,9 +23,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="detail-page">
|
<main class="detail-page">
|
||||||
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
|
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
|
||||||
|
>Back to catalog</RouterLink
|
||||||
|
>
|
||||||
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
|
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
|
||||||
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
|
<p v-else-if="error" class="catalog-state catalog-state--error">
|
||||||
|
{{ error.message }}
|
||||||
|
</p>
|
||||||
<CollectibleDetail
|
<CollectibleDetail
|
||||||
v-else-if="listing"
|
v-else-if="listing"
|
||||||
:title="listing.productName"
|
:title="listing.productName"
|
||||||
@@ -33,9 +37,13 @@ onMounted(async () => {
|
|||||||
:price-minor="listing.askingPriceMinor"
|
:price-minor="listing.askingPriceMinor"
|
||||||
:market-value-minor="listing.marketValueMinor"
|
:market-value-minor="listing.marketValueMinor"
|
||||||
:market-value-currency="listing.marketValueCurrency"
|
:market-value-currency="listing.marketValueCurrency"
|
||||||
:condition="listing.conditionName ? `${listing.conditionName} (${listing.conditionCode})` : listing.conditionId"
|
:condition="
|
||||||
|
listing.conditionName
|
||||||
|
? `${listing.conditionName} (${listing.conditionCode})`
|
||||||
|
: listing.conditionId
|
||||||
|
"
|
||||||
:description="listing.notes"
|
:description="listing.notes"
|
||||||
:images="listing.images"
|
:images="listing.images"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
...options,
|
...options,
|
||||||
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
headers: {
|
||||||
|
...(options.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Acquisition request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Acquisition request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminAcquisitions() {
|
export function listAdminAcquisitions() {
|
||||||
return request('/admin/acquisitions');
|
return request("/admin/acquisitions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAdminAcquisition(input, csrfToken) {
|
export function createAdminAcquisition(input, csrfToken) {
|
||||||
return request('/admin/acquisitions', {
|
return request("/admin/acquisitions", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
headers: { "x-csrf-token": csrfToken },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
export async function getAdminDashboard() {
|
export async function getAdminDashboard() {
|
||||||
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, { credentials: 'include' });
|
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Dashboard request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Dashboard request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
...options,
|
...options,
|
||||||
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
headers: {
|
||||||
|
...(options.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Job request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Job request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminPricingJobs() {
|
export function listAdminPricingJobs() {
|
||||||
return request('/admin/jobs');
|
return request("/admin/jobs");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retryAdminPricingJob(jobId, csrfToken) {
|
export function retryAdminPricingJob(jobId, csrfToken) {
|
||||||
return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, {
|
return request(`/admin/jobs/${encodeURIComponent(jobId)}/retry`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
headers: { "x-csrf-token": csrfToken },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers };
|
const headers = {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers });
|
...(options.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Listing request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminListings() { return request('/admin/listings'); }
|
export function listAdminListings() {
|
||||||
export function createAdminListing(input, csrfToken) { return request('/admin/listings', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(input) }); }
|
return request("/admin/listings");
|
||||||
export function publishAdminListing(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/publish`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); }
|
}
|
||||||
export function markListingPending(id, csrfToken) { return request(`/admin/listings/${encodeURIComponent(id)}/pending`, { method: 'POST', headers: { 'x-csrf-token': csrfToken } }); }
|
export function createAdminListing(input, csrfToken) {
|
||||||
|
return request("/admin/listings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function publishAdminListing(id, csrfToken) {
|
||||||
|
return request(`/admin/listings/${encodeURIComponent(id)}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function markListingPending(id, csrfToken) {
|
||||||
|
return request(`/admin/listings/${encodeURIComponent(id)}/pending`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,58 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
...options,
|
...options,
|
||||||
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
headers: {
|
||||||
|
...(options.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Pricing request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Pricing request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addAdminPricingObservation(input, csrfToken) {
|
export function addAdminPricingObservation(input, csrfToken) {
|
||||||
return request('/admin/pricing/observations', {
|
return request("/admin/pricing/observations", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
headers: { "x-csrf-token": csrfToken },
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateAdminValuation(inventoryItemId, csrfToken) {
|
export function calculateAdminValuation(inventoryItemId, csrfToken) {
|
||||||
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`, {
|
return request(
|
||||||
method: 'POST',
|
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`,
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function overrideAdminValuation(inventoryItemId, input, csrfToken) {
|
export function overrideAdminValuation(inventoryItemId, input, csrfToken) {
|
||||||
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`, {
|
return request(
|
||||||
method: 'POST',
|
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation/override`,
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
{
|
||||||
body: JSON.stringify(input),
|
method: "POST",
|
||||||
});
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
|
export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
|
||||||
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`, {
|
return request(
|
||||||
method: 'POST',
|
`/admin/inventory/${encodeURIComponent(inventoryItemId)}/pricing/refresh`,
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
{
|
||||||
body: JSON.stringify(input),
|
method: "POST",
|
||||||
});
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminPricingJob(jobId) {
|
export function getAdminPricingJob(jobId) {
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
...options,
|
...options,
|
||||||
headers: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
headers: {
|
||||||
|
...(options.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Provider mapping request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
payload?.error?.message ?? "Provider mapping request failed.",
|
||||||
|
);
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminProviderMappings(productId) {
|
export function listAdminProviderMappings(productId) {
|
||||||
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`);
|
return request(
|
||||||
|
`/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAdminProviderMapping(productId, input, csrfToken) {
|
export function createAdminProviderMapping(productId, input, csrfToken) {
|
||||||
return request(`/admin/products/${encodeURIComponent(productId)}/provider-mappings`, {
|
return request(
|
||||||
method: 'POST',
|
`/admin/products/${encodeURIComponent(productId)}/provider-mappings`,
|
||||||
headers: { 'x-csrf-token': csrfToken },
|
{
|
||||||
body: JSON.stringify(input),
|
method: "POST",
|
||||||
});
|
headers: { "x-csrf-token": csrfToken },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,46 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
export async function createAdminSale(input, csrfToken) {
|
export async function createAdminSale(input, csrfToken) {
|
||||||
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
|
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
"content-type": "application/json",
|
||||||
'x-csrf-token': csrfToken,
|
"x-csrf-token": csrfToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale request failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Sale request failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAdminSales() {
|
export async function listAdminSales() {
|
||||||
const response = await fetch(`${apiBaseUrl}/admin/sales`, { credentials: 'include' });
|
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale history is unavailable.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Sale history is unavailable.");
|
||||||
return payload?.data ?? [];
|
return payload?.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function voidAdminSale(id, reason, csrfToken) {
|
export async function voidAdminSale(id, reason, csrfToken) {
|
||||||
const response = await fetch(`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`,
|
||||||
credentials: 'include',
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
'content-type': 'application/json',
|
credentials: "include",
|
||||||
'x-csrf-token': csrfToken,
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-csrf-token": csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ reason }),
|
);
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale correction failed.');
|
if (!response.ok)
|
||||||
|
throw new Error(payload?.error?.message ?? "Sale correction failed.");
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
export async function searchCatalog({ query = '', category = '', conditionId = '', minPriceMinor, maxPriceMinor, sort = 'recent', page = 1, limit = 20 } = {}) {
|
export async function searchCatalog({
|
||||||
const params = new URLSearchParams({ query, page: String(page), limit: String(limit) });
|
query = "",
|
||||||
if (sort !== 'recent') params.set('sort', sort);
|
category = "",
|
||||||
if (category) params.set('category', category);
|
conditionId = "",
|
||||||
if (conditionId) params.set('conditionId', conditionId);
|
minPriceMinor,
|
||||||
if (typeof minPriceMinor === 'number') params.set('minPriceMinor', String(minPriceMinor));
|
maxPriceMinor,
|
||||||
if (typeof maxPriceMinor === 'number') params.set('maxPriceMinor', String(maxPriceMinor));
|
sort = "recent",
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
} = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query,
|
||||||
|
page: String(page),
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
if (sort !== "recent") params.set("sort", sort);
|
||||||
|
if (category) params.set("category", category);
|
||||||
|
if (conditionId) params.set("conditionId", conditionId);
|
||||||
|
if (typeof minPriceMinor === "number")
|
||||||
|
params.set("minPriceMinor", String(minPriceMinor));
|
||||||
|
if (typeof maxPriceMinor === "number")
|
||||||
|
params.set("maxPriceMinor", String(maxPriceMinor));
|
||||||
const response = await fetch(`${apiBaseUrl}/public/search?${params}`);
|
const response = await fetch(`${apiBaseUrl}/public/search?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Catalog search is unavailable.');
|
throw new Error("Catalog search is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -19,21 +34,27 @@ export async function searchCatalog({ query = '', category = '', conditionId = '
|
|||||||
|
|
||||||
export async function listPublicCategories() {
|
export async function listPublicCategories() {
|
||||||
const response = await fetch(`${apiBaseUrl}/public/lookups/categories`);
|
const response = await fetch(`${apiBaseUrl}/public/lookups/categories`);
|
||||||
if (!response.ok) throw new Error('Categories are unavailable.');
|
if (!response.ok) throw new Error("Categories are unavailable.");
|
||||||
return (await response.json()).data;
|
return (await response.json()).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPublicConditions() {
|
export async function listPublicConditions() {
|
||||||
const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`);
|
const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`);
|
||||||
if (!response.ok) throw new Error('Conditions are unavailable.');
|
if (!response.ok) throw new Error("Conditions are unavailable.");
|
||||||
return (await response.json()).data;
|
return (await response.json()).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublicListing(id) {
|
export async function getPublicListing(id) {
|
||||||
const response = await fetch(`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`);
|
const response = await fetch(
|
||||||
|
`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.status === 404 ? 'Listing not found.' : 'Listing is unavailable.');
|
throw new Error(
|
||||||
|
response.status === 404
|
||||||
|
? "Listing not found."
|
||||||
|
: "Listing is unavailable.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -41,12 +62,18 @@ export async function getPublicListing(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublicProduct(slug) {
|
export async function getPublicProduct(slug) {
|
||||||
const response = await fetch(`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`);
|
const response = await fetch(
|
||||||
|
`${apiBaseUrl}/public/products/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.status === 404 ? 'Product not found.' : 'Product is unavailable.');
|
throw new Error(
|
||||||
|
response.status === 404
|
||||||
|
? "Product not found."
|
||||||
|
: "Product is unavailable.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
return payload.data;
|
return payload.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,181 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
const tinyPng = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
test('signs in and publishes a listing after adding an inventory image', async ({ page }) => {
|
test("signs in and publishes a listing after adding an inventory image", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const email = process.env.INITIAL_ADMIN_EMAIL;
|
const email = process.env.INITIAL_ADMIN_EMAIL;
|
||||||
const password = process.env.INITIAL_ADMIN_PASSWORD;
|
const password = process.env.INITIAL_ADMIN_PASSWORD;
|
||||||
|
|
||||||
test.skip(!email || !password, 'Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.');
|
test.skip(
|
||||||
|
!email || !password,
|
||||||
|
"Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD for the local authenticated workflow.",
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto('/login');
|
await page.goto("/login");
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel("Email").fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel("Password").fill(password);
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
await expect(page).toHaveURL(/\/admin$/);
|
await expect(page).toHaveURL(/\/admin$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0);
|
page.getByRole("heading", { name: "Collector workspace" }),
|
||||||
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText('Active inventory')).toBeVisible();
|
await expect(
|
||||||
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Missing images' }).getByRole('link')).toHaveAttribute('href', '#inventory-title');
|
page.getByRole("status", { name: "Loading inventory..." }),
|
||||||
await expect(page.locator('.dashboard-summary__grid div').filter({ hasText: 'Provider failures' }).getByRole('link')).toHaveAttribute('href', '#pricing-title');
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "At a glance" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Active inventory")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator(".dashboard-summary__grid div")
|
||||||
|
.filter({ hasText: "Missing images" })
|
||||||
|
.getByRole("link"),
|
||||||
|
).toHaveAttribute("href", "#inventory-title");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator(".dashboard-summary__grid div")
|
||||||
|
.filter({ hasText: "Provider failures" })
|
||||||
|
.getByRole("link"),
|
||||||
|
).toHaveAttribute("href", "#pricing-title");
|
||||||
|
|
||||||
const category = page.locator('#product-category option').nth(1);
|
const category = page.locator("#product-category option").nth(1);
|
||||||
await expect(category).toBeAttached();
|
await expect(category).toBeAttached();
|
||||||
const productName = `Browser test collectible ${Date.now()}`;
|
const productName = `Browser test collectible ${Date.now()}`;
|
||||||
await page.getByLabel('Name').fill(productName);
|
await page.getByLabel("Name").fill(productName);
|
||||||
await page.getByLabel('Category').selectOption({ index: 1 });
|
await page.getByLabel("Category").selectOption({ index: 1 });
|
||||||
await page.getByRole('button', { name: 'Create product' }).click();
|
await page.getByRole("button", { name: "Create product" }).click();
|
||||||
|
|
||||||
await expect(page.locator('#inventory-product option').nth(1)).toBeAttached();
|
await expect(page.locator("#inventory-product option").nth(1)).toBeAttached();
|
||||||
const createdProductId = await page.locator('#inventory-product').locator('option', { hasText: productName }).getAttribute('value');
|
const createdProductId = await page
|
||||||
await page.locator('#mapping-product').selectOption(createdProductId);
|
.locator("#inventory-product")
|
||||||
await page.getByLabel('Provider ID').fill('fixture-provider');
|
.locator("option", { hasText: productName })
|
||||||
await page.getByLabel('External identifier').fill('fixture-product-1');
|
.getAttribute("value");
|
||||||
await page.getByRole('button', { name: 'Save provider mapping' }).click();
|
await page.locator("#mapping-product").selectOption(createdProductId);
|
||||||
await expect(page.getByRole('region', { name: 'Active provider mappings' })).toContainText('fixture-provider');
|
await page.getByLabel("Provider ID").fill("fixture-provider");
|
||||||
await page.locator('#inventory-product').selectOption(createdProductId);
|
await page.getByLabel("External identifier").fill("fixture-product-1");
|
||||||
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
|
await page.getByRole("button", { name: "Save provider mapping" }).click();
|
||||||
await page.getByRole('button', { name: 'Create copy' }).click();
|
await expect(
|
||||||
|
page.getByRole("region", { name: "Active provider mappings" }),
|
||||||
|
).toContainText("fixture-provider");
|
||||||
|
await page.locator("#inventory-product").selectOption(createdProductId);
|
||||||
|
await page
|
||||||
|
.getByLabel("Condition", { exact: true })
|
||||||
|
.selectOption({ index: 1 });
|
||||||
|
await page.getByRole("button", { name: "Create copy" }).click();
|
||||||
|
|
||||||
const createdInventoryRow = page.locator(`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`).last();
|
const createdInventoryRow = page
|
||||||
const createdInventoryId = await createdInventoryRow.locator('a').getAttribute('href').then((href) => href.split('/').at(-2));
|
.locator(
|
||||||
const inventoryLink = page.locator(`a[href="/admin/inventory/${createdInventoryId}/images"]`);
|
`section[aria-labelledby="inventory-title"] li[data-product-id="${createdProductId}"]`,
|
||||||
|
)
|
||||||
|
.last();
|
||||||
|
const createdInventoryId = await createdInventoryRow
|
||||||
|
.locator("a")
|
||||||
|
.getAttribute("href")
|
||||||
|
.then((href) => href.split("/").at(-2));
|
||||||
|
const inventoryLink = page.locator(
|
||||||
|
`a[href="/admin/inventory/${createdInventoryId}/images"]`,
|
||||||
|
);
|
||||||
await expect(inventoryLink).toBeVisible();
|
await expect(inventoryLink).toBeVisible();
|
||||||
await page.locator('#acquisition-inventory').selectOption(createdInventoryId);
|
await page.locator("#acquisition-inventory").selectOption(createdInventoryId);
|
||||||
await page.getByLabel('Method').fill('PURCHASE');
|
await page.getByLabel("Method").fill("PURCHASE");
|
||||||
await page.getByLabel('Acquisition date').fill('2026-09-09');
|
await page.getByLabel("Acquisition date").fill("2026-09-09");
|
||||||
await page.getByLabel('Cost (cents)').fill('1000');
|
await page.getByLabel("Cost (cents)").fill("1000");
|
||||||
await page.getByLabel('Shipping (cents)').fill('100');
|
await page.getByLabel("Shipping (cents)").fill("100");
|
||||||
await page.getByLabel('Tax (cents)').fill('50');
|
await page.getByLabel("Tax (cents)").fill("50");
|
||||||
await page.getByRole('button', { name: 'Record acquisition' }).click();
|
await page.getByRole("button", { name: "Record acquisition" }).click();
|
||||||
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50');
|
await expect(
|
||||||
await page.locator('#pricing-inventory').selectOption(createdInventoryId);
|
page.locator('section[aria-labelledby="acquisitions-title"] li').first(),
|
||||||
await page.getByLabel('Source identifier').fill('browser-price-1');
|
).toContainText("Basis $11.50");
|
||||||
await page.getByLabel('Observed price (cents)').fill('2500');
|
await page.locator("#pricing-inventory").selectOption(createdInventoryId);
|
||||||
|
await page.getByLabel("Source identifier").fill("browser-price-1");
|
||||||
|
await page.getByLabel("Observed price (cents)").fill("2500");
|
||||||
const [observationResponse, valuationResponse] = await Promise.all([
|
const [observationResponse, valuationResponse] = await Promise.all([
|
||||||
page.waitForResponse((response) => response.url().includes('/admin/pricing/observations')),
|
page.waitForResponse((response) =>
|
||||||
page.waitForResponse((response) => response.url().includes('/valuation') && response.request().method() === 'POST'),
|
response.url().includes("/admin/pricing/observations"),
|
||||||
page.getByRole('button', { name: 'Add observation and calculate' }).click(),
|
),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/valuation") &&
|
||||||
|
response.request().method() === "POST",
|
||||||
|
),
|
||||||
|
page.getByRole("button", { name: "Add observation and calculate" }).click(),
|
||||||
]);
|
]);
|
||||||
expect(observationResponse.status()).toBe(201);
|
expect(observationResponse.status()).toBe(201);
|
||||||
expect(valuationResponse.status()).toBe(200);
|
expect(valuationResponse.status()).toBe(200);
|
||||||
const valuationRow = page.getByRole('list', { name: 'Market valuations' }).getByText(createdInventoryId);
|
const valuationRow = page
|
||||||
await expect(valuationRow).toContainText('Market value $25.00');
|
.getByRole("list", { name: "Market valuations" })
|
||||||
await page.getByLabel('Manual value (cents)').fill('2700');
|
.getByText(createdInventoryId);
|
||||||
await page.getByLabel('Override reason').fill('Verified recent sale');
|
await expect(valuationRow).toContainText("Market value $25.00");
|
||||||
await page.getByRole('button', { name: 'Save manual value' }).click();
|
await page.getByLabel("Manual value (cents)").fill("2700");
|
||||||
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $27.00');
|
await page.getByLabel("Override reason").fill("Verified recent sale");
|
||||||
await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('OVERRIDE confidence');
|
await page.getByRole("button", { name: "Save manual value" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("list", { name: "Market valuations" }),
|
||||||
|
).toContainText("Market value $27.00");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("list", { name: "Market valuations" }),
|
||||||
|
).toContainText("OVERRIDE confidence");
|
||||||
await inventoryLink.click();
|
await inventoryLink.click();
|
||||||
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
await expect(
|
||||||
await page.getByLabel('Add image').setInputFiles({
|
page.getByRole("heading", { name: "Manage images" }),
|
||||||
name: 'front.png',
|
).toBeVisible();
|
||||||
mimeType: 'image/png',
|
await page.getByLabel("Add image").setInputFiles({
|
||||||
|
name: "front.png",
|
||||||
|
mimeType: "image/png",
|
||||||
buffer: tinyPng,
|
buffer: tinyPng,
|
||||||
});
|
});
|
||||||
await expect(page.getByLabel('Alt text')).toHaveValue('front.png');
|
await expect(page.getByLabel("Alt text")).toHaveValue("front.png");
|
||||||
|
|
||||||
await page.goto('/admin');
|
await page.goto("/admin");
|
||||||
await expect(page.getByRole('heading', { name: 'Create listing draft' })).toBeVisible();
|
await expect(
|
||||||
await page.getByLabel('Inventory copy', { exact: true }).first().selectOption(createdInventoryId);
|
page.getByRole("heading", { name: "Create listing draft" }),
|
||||||
await page.getByLabel('Asking price (cents)').fill('2499');
|
).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Create listing' }).click();
|
await page
|
||||||
const createdListing = page.locator('section[aria-labelledby="listings-title"] article').filter({ hasText: productName }).last();
|
.getByLabel("Inventory copy", { exact: true })
|
||||||
await expect(createdListing.getByText('Add at least one image before publishing.')).toHaveCount(0);
|
.first()
|
||||||
await createdListing.getByRole('button', { name: 'Publish' }).click();
|
.selectOption(createdInventoryId);
|
||||||
await expect(createdListing).toContainText('AVAILABLE');
|
await page.getByLabel("Asking price (cents)").fill("2499");
|
||||||
|
await page.getByRole("button", { name: "Create listing" }).click();
|
||||||
|
const createdListing = page
|
||||||
|
.locator('section[aria-labelledby="listings-title"] article')
|
||||||
|
.filter({ hasText: productName })
|
||||||
|
.last();
|
||||||
|
await expect(
|
||||||
|
createdListing.getByText("Add at least one image before publishing."),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await createdListing.getByRole("button", { name: "Publish" }).click();
|
||||||
|
await expect(createdListing).toContainText("AVAILABLE");
|
||||||
|
|
||||||
await page.locator('#sale-inventory').selectOption(createdInventoryId);
|
await page.locator("#sale-inventory").selectOption(createdInventoryId);
|
||||||
await page.getByLabel('Sale date').fill('2026-09-09');
|
await page.getByLabel("Sale date").fill("2026-09-09");
|
||||||
await page.getByLabel('Sold price (cents)').fill('2499');
|
await page.getByLabel("Sold price (cents)").fill("2499");
|
||||||
await page.getByLabel('Cost basis (cents)').fill('1200');
|
await page.getByLabel("Cost basis (cents)").fill("1200");
|
||||||
await page.getByRole('button', { name: 'Record sale' }).click();
|
await page.getByRole("button", { name: "Record sale" }).click();
|
||||||
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · SOLD`)).toBeVisible();
|
await expect(
|
||||||
await expect(page.getByRole('heading', { name: 'Sale history' })).toBeVisible();
|
page
|
||||||
const latestSale = page.locator('section[aria-labelledby="sales-title"] li').first();
|
.locator('section[aria-labelledby="inventory-title"]')
|
||||||
await expect(latestSale).toContainText('Profit $12.99');
|
.getByText(`${createdInventoryId} · SOLD`),
|
||||||
await latestSale.getByLabel('Void reason').fill('Browser test correction');
|
).toBeVisible();
|
||||||
await latestSale.getByRole('button', { name: 'Void sale' }).click();
|
await expect(
|
||||||
await expect(latestSale).toContainText('VOIDED');
|
page.getByRole("heading", { name: "Sale history" }),
|
||||||
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · ACTIVE`)).toBeVisible();
|
).toBeVisible();
|
||||||
|
const latestSale = page
|
||||||
|
.locator('section[aria-labelledby="sales-title"] li')
|
||||||
|
.first();
|
||||||
|
await expect(latestSale).toContainText("Profit $12.99");
|
||||||
|
await latestSale.getByLabel("Void reason").fill("Browser test correction");
|
||||||
|
await latestSale.getByRole("button", { name: "Void sale" }).click();
|
||||||
|
await expect(latestSale).toContainText("VOIDED");
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('section[aria-labelledby="inventory-title"]')
|
||||||
|
.getByText(`${createdInventoryId} · ACTIVE`),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
export async function processNextPersistentPricingRefreshJob({ jobRepository, pricingRefreshJob }) {
|
export async function processNextPersistentPricingRefreshJob({
|
||||||
|
jobRepository,
|
||||||
|
pricingRefreshJob,
|
||||||
|
}) {
|
||||||
const claimed = await jobRepository.claimNext();
|
const claimed = await jobRepository.claimNext();
|
||||||
if (!claimed) return null;
|
if (!claimed) return null;
|
||||||
|
|
||||||
const result = await pricingRefreshJob({ ...claimed, type: 'PRICING_REFRESH' });
|
const result = await pricingRefreshJob({
|
||||||
if (result.status === 'COMPLETED') {
|
...claimed,
|
||||||
await jobRepository.complete(claimed.id, result.observations, result.valuation);
|
type: "PRICING_REFRESH",
|
||||||
|
});
|
||||||
|
if (result.status === "COMPLETED") {
|
||||||
|
await jobRepository.complete(
|
||||||
|
claimed.id,
|
||||||
|
result.observations,
|
||||||
|
result.valuation,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await jobRepository.fail(claimed.id, result.error);
|
await jobRepository.fail(claimed.id, result.error);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}export async function runPricingRefreshJob({ job, providerRegistry, pricingService }) {
|
}
|
||||||
if (!job || job.type !== 'PRICING_REFRESH') {
|
export async function runPricingRefreshJob({
|
||||||
throw new Error('Unsupported pricing refresh job.');
|
job,
|
||||||
|
providerRegistry,
|
||||||
|
pricingService,
|
||||||
|
}) {
|
||||||
|
if (!job || job.type !== "PRICING_REFRESH") {
|
||||||
|
throw new Error("Unsupported pricing refresh job.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = providerRegistry?.get(job.providerId);
|
const provider = providerRegistry?.get(job.providerId);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
status: 'FAILED',
|
status: "FAILED",
|
||||||
error: 'Pricing provider is not configured.',
|
error: "Pricing provider is not configured.",
|
||||||
failedAt: new Date(),
|
failedAt: new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -29,17 +44,21 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
|
|||||||
inventoryItemId: job.inventoryItemId,
|
inventoryItemId: job.inventoryItemId,
|
||||||
productId: job.productId,
|
productId: job.productId,
|
||||||
conditionId: job.conditionId,
|
conditionId: job.conditionId,
|
||||||
|
externalIdentifier: job.externalIdentifier,
|
||||||
|
searchQuery: job.searchQuery,
|
||||||
});
|
});
|
||||||
const savedObservations = [];
|
const savedObservations = [];
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
savedObservations.push(await pricingService.addObservation({
|
savedObservations.push(
|
||||||
sellerId: job.sellerId,
|
await pricingService.addObservation({
|
||||||
...observation,
|
sellerId: job.sellerId,
|
||||||
providerId: provider.id,
|
...observation,
|
||||||
inventoryItemId: job.inventoryItemId,
|
providerId: provider.id,
|
||||||
productId: job.productId,
|
inventoryItemId: job.inventoryItemId,
|
||||||
conditionId: job.conditionId,
|
productId: job.productId,
|
||||||
}));
|
conditionId: job.conditionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const valuation = await pricingService.calculate({
|
const valuation = await pricingService.calculate({
|
||||||
sellerId: job.sellerId,
|
sellerId: job.sellerId,
|
||||||
@@ -48,7 +67,7 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
status: 'COMPLETED',
|
status: "COMPLETED",
|
||||||
observations: savedObservations,
|
observations: savedObservations,
|
||||||
valuation,
|
valuation,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
@@ -56,8 +75,8 @@ export async function processNextPersistentPricingRefreshJob({ jobRepository, pr
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
status: 'FAILED',
|
status: "FAILED",
|
||||||
error: error instanceof Error ? error.message : 'Pricing refresh failed.',
|
error: error instanceof Error ? error.message : "Pricing refresh failed.",
|
||||||
failedAt: new Date(),
|
failedAt: new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,59 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) });
|
dotenv.config({
|
||||||
|
path: fileURLToPath(new URL("../../../.env", import.meta.url)),
|
||||||
|
});
|
||||||
|
|
||||||
import { createDatabase } from '../../api/src/infrastructure/database/client.js';
|
import { createDatabase } from "../../api/src/infrastructure/database/client.js";
|
||||||
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../api/src/infrastructure/images/local-image-storage.js';
|
import {
|
||||||
import { createImageDerivativeProcessor } from '../../api/src/infrastructure/images/image-derivative-processor.js';
|
createLocalImageStorage,
|
||||||
import { createSharpImageRenderer } from '../../api/src/infrastructure/images/sharp-image-renderer.js';
|
resolveProjectImageStorageRoot,
|
||||||
import { createImageDerivativeJobRepository } from '../../api/src/modules/inventory/repositories/image-derivative-job-repository.js';
|
} from "../../api/src/infrastructure/images/local-image-storage.js";
|
||||||
import { createPricingRepository } from '../../api/src/modules/pricing/repositories/pricing-repository.js';
|
import { createImageDerivativeProcessor } from "../../api/src/infrastructure/images/image-derivative-processor.js";
|
||||||
import { createPricingRefreshJobRepository } from '../../api/src/modules/pricing/repositories/pricing-refresh-job-repository.js';
|
import { createSharpImageRenderer } from "../../api/src/infrastructure/images/sharp-image-renderer.js";
|
||||||
import { createPricingService } from '../../api/src/modules/pricing/services/pricing-service.js';
|
import { createImageDerivativeJobRepository } from "../../api/src/modules/inventory/repositories/image-derivative-job-repository.js";
|
||||||
import { createPricingProviderRegistry } from '../../api/src/modules/pricing/providers/pricing-provider.js';
|
import { createPricingRepository } from "../../api/src/modules/pricing/repositories/pricing-repository.js";
|
||||||
import { createFixturePricingProvider } from '../../api/src/modules/pricing/providers/fixture-pricing-provider.js';
|
import { createPricingRefreshJobRepository } from "../../api/src/modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||||
import { processNextPersistentImageDerivativeJob } from './image-derivative-worker.js';
|
import { createPricingService } from "../../api/src/modules/pricing/services/pricing-service.js";
|
||||||
import { processNextPersistentPricingRefreshJob, runPricingRefreshJob } from './pricing-refresh-worker.js';
|
import { createPricingProviderRegistry } from "../../api/src/modules/pricing/providers/pricing-provider.js";
|
||||||
|
import { createFixturePricingProvider } from "../../api/src/modules/pricing/providers/fixture-pricing-provider.js";
|
||||||
|
import { createHttpPricingProvider } from "../../api/src/modules/pricing/providers/http-pricing-provider.js";
|
||||||
|
import { processNextPersistentImageDerivativeJob } from "./image-derivative-worker.js";
|
||||||
|
import {
|
||||||
|
processNextPersistentPricingRefreshJob,
|
||||||
|
runPricingRefreshJob,
|
||||||
|
} from "./pricing-refresh-worker.js";
|
||||||
|
|
||||||
const pollIntervalMs = Number.parseInt(process.env.WORKER_POLL_INTERVAL_MS ?? '2000', 10);
|
const pollIntervalMs = Number.parseInt(
|
||||||
|
process.env.WORKER_POLL_INTERVAL_MS ?? "2000",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
async function runWorker() {
|
async function runWorker() {
|
||||||
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
const imageStorageRoot = resolveProjectImageStorageRoot(
|
||||||
|
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const imageStorage = createLocalImageStorage(imageStorageRoot);
|
const imageStorage = createLocalImageStorage(imageStorageRoot);
|
||||||
const jobRepository = createImageDerivativeJobRepository(database.db);
|
const jobRepository = createImageDerivativeJobRepository(database.db);
|
||||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||||
const pricingProviderRegistry = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true'
|
database.db,
|
||||||
? createPricingProviderRegistry([createFixturePricingProvider()])
|
);
|
||||||
: createPricingProviderRegistry();
|
const providers = [];
|
||||||
|
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
||||||
|
providers.push(createFixturePricingProvider());
|
||||||
|
if (process.env.PRICING_HTTP_PROVIDER_ENDPOINT) {
|
||||||
|
providers.push(
|
||||||
|
createHttpPricingProvider({
|
||||||
|
id: process.env.PRICING_HTTP_PROVIDER_ID ?? "http-provider",
|
||||||
|
endpoint: process.env.PRICING_HTTP_PROVIDER_ENDPOINT,
|
||||||
|
token: process.env.PRICING_HTTP_PROVIDER_TOKEN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pricingProviderRegistry = createPricingProviderRegistry(providers);
|
||||||
const pricingService = createPricingService({
|
const pricingService = createPricingService({
|
||||||
pricingRepository: createPricingRepository(database.db),
|
pricingRepository: createPricingRepository(database.db),
|
||||||
pricingProviderRegistry,
|
pricingProviderRegistry,
|
||||||
@@ -45,32 +72,42 @@ async function runWorker() {
|
|||||||
processor,
|
processor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) console.log(`Image derivative job ${result.id} ${result.status.toLowerCase()}.`);
|
if (result)
|
||||||
|
console.log(
|
||||||
|
`Image derivative job ${result.id} ${result.status.toLowerCase()}.`,
|
||||||
|
);
|
||||||
|
|
||||||
const pricingResult = await processNextPersistentPricingRefreshJob({
|
const pricingResult = await processNextPersistentPricingRefreshJob({
|
||||||
jobRepository: pricingRefreshJobRepository,
|
jobRepository: pricingRefreshJobRepository,
|
||||||
pricingRefreshJob: (job) => runPricingRefreshJob({
|
pricingRefreshJob: (job) =>
|
||||||
job,
|
runPricingRefreshJob({
|
||||||
providerRegistry: pricingProviderRegistry,
|
job,
|
||||||
pricingService,
|
providerRegistry: pricingProviderRegistry,
|
||||||
}),
|
pricingService,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pricingResult) console.log(`Pricing refresh job ${pricingResult.id} ${pricingResult.status.toLowerCase()}.`);
|
if (pricingResult)
|
||||||
|
console.log(
|
||||||
|
`Pricing refresh job ${pricingResult.id} ${pricingResult.status.toLowerCase()}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await processOne();
|
await processOne();
|
||||||
const timer = setInterval(() => processOne().catch((error) => console.error(error)), pollIntervalMs);
|
const timer = setInterval(
|
||||||
|
() => processOne().catch((error) => console.error(error)),
|
||||||
|
pollIntervalMs,
|
||||||
|
);
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
await database.close();
|
await database.close();
|
||||||
};
|
};
|
||||||
process.once('SIGINT', shutdown);
|
process.once("SIGINT", shutdown);
|
||||||
process.once('SIGTERM', shutdown);
|
process.once("SIGTERM", shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
runWorker().catch((error) => {
|
runWorker().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,78 +1,111 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from "node:assert/strict";
|
||||||
import test from 'node:test';
|
import test from "node:test";
|
||||||
|
|
||||||
import { createPricingProvider, createPricingProviderRegistry } from '../../api/src/modules/pricing/providers/pricing-provider.js';
|
import {
|
||||||
import { processNextPersistentPricingRefreshJob, runPricingRefreshJob } from '../src/pricing-refresh-worker.js';
|
createPricingProvider,
|
||||||
|
createPricingProviderRegistry,
|
||||||
|
} from "../../api/src/modules/pricing/providers/pricing-provider.js";
|
||||||
|
import {
|
||||||
|
processNextPersistentPricingRefreshJob,
|
||||||
|
runPricingRefreshJob,
|
||||||
|
} from "../src/pricing-refresh-worker.js";
|
||||||
|
|
||||||
test('completes a pricing refresh through the provider and pricing service boundaries', async () => {
|
test("completes a pricing refresh through the provider and pricing service boundaries", async () => {
|
||||||
const providerRegistry = createPricingProviderRegistry([
|
const providerRegistry = createPricingProviderRegistry([
|
||||||
createPricingProvider({
|
createPricingProvider({
|
||||||
id: 'fixture-provider',
|
id: "fixture-provider",
|
||||||
async observe() {
|
async observe() {
|
||||||
return [{ priceMinor: 2500, sourceIdentifier: 'fixture-1' }];
|
return [{ priceMinor: 2500, sourceIdentifier: "fixture-1" }];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const saved = [];
|
const saved = [];
|
||||||
const result = await runPricingRefreshJob({
|
const result = await runPricingRefreshJob({
|
||||||
job: {
|
job: {
|
||||||
id: 'job-1',
|
id: "job-1",
|
||||||
type: 'PRICING_REFRESH',
|
type: "PRICING_REFRESH",
|
||||||
providerId: 'fixture-provider',
|
providerId: "fixture-provider",
|
||||||
sellerId: 'seller-1',
|
sellerId: "seller-1",
|
||||||
productId: 'product-1',
|
productId: "product-1",
|
||||||
inventoryItemId: 'inventory-1',
|
inventoryItemId: "inventory-1",
|
||||||
conditionId: 'condition-1',
|
conditionId: "condition-1",
|
||||||
},
|
},
|
||||||
providerRegistry,
|
providerRegistry,
|
||||||
pricingService: {
|
pricingService: {
|
||||||
async addObservation(input) { saved.push(input); return input; },
|
async addObservation(input) {
|
||||||
async calculate() { return { valuation: { valueMinor: 2500 } }; },
|
saved.push(input);
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
async calculate() {
|
||||||
|
return { valuation: { valueMinor: 2500 } };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.status, 'COMPLETED');
|
assert.equal(result.status, "COMPLETED");
|
||||||
assert.equal(result.valuation.valuation.valueMinor, 2500);
|
assert.equal(result.valuation.valuation.valueMinor, 2500);
|
||||||
assert.equal(saved[0].providerId, 'fixture-provider');
|
assert.equal(saved[0].providerId, "fixture-provider");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fails a refresh clearly when a provider is unavailable', async () => {
|
test("fails a refresh clearly when a provider is unavailable", async () => {
|
||||||
const result = await runPricingRefreshJob({
|
const result = await runPricingRefreshJob({
|
||||||
job: { id: 'job-2', type: 'PRICING_REFRESH', providerId: 'missing' },
|
job: { id: "job-2", type: "PRICING_REFRESH", providerId: "missing" },
|
||||||
providerRegistry: createPricingProviderRegistry(),
|
providerRegistry: createPricingProviderRegistry(),
|
||||||
pricingService: {},
|
pricingService: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.status, 'FAILED');
|
assert.equal(result.status, "FAILED");
|
||||||
assert.match(result.error, /not configured/);
|
assert.match(result.error, /not configured/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persists completed and failed outcomes through the job repository', async () => {
|
test("persists completed and failed outcomes through the job repository", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const jobRepository = {
|
const jobRepository = {
|
||||||
async claimNext() { return { id: 'job-3', type: 'PRICING_REFRESH', providerId: 'fixture' }; },
|
async claimNext() {
|
||||||
async complete(id, observations, valuation) { calls.push(['complete', id, observations, valuation]); },
|
return { id: "job-3", type: "PRICING_REFRESH", providerId: "fixture" };
|
||||||
async fail(id, error) { calls.push(['fail', id, error]); },
|
},
|
||||||
|
async complete(id, observations, valuation) {
|
||||||
|
calls.push(["complete", id, observations, valuation]);
|
||||||
|
},
|
||||||
|
async fail(id, error) {
|
||||||
|
calls.push(["fail", id, error]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await processNextPersistentPricingRefreshJob({
|
await processNextPersistentPricingRefreshJob({
|
||||||
jobRepository,
|
jobRepository,
|
||||||
async pricingRefreshJob() { return { status: 'COMPLETED', observations: [{ id: 'observation-1' }], valuation: { valueMinor: 2500 } }; },
|
async pricingRefreshJob() {
|
||||||
|
return {
|
||||||
|
status: "COMPLETED",
|
||||||
|
observations: [{ id: "observation-1" }],
|
||||||
|
valuation: { valueMinor: 2500 },
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [['complete', 'job-3', [{ id: 'observation-1' }], { valueMinor: 2500 }]]);
|
assert.deepEqual(calls, [
|
||||||
|
["complete", "job-3", [{ id: "observation-1" }], { valueMinor: 2500 }],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persists failed pricing refresh outcomes through the repository', async () => {
|
test("persists failed pricing refresh outcomes through the repository", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
await processNextPersistentPricingRefreshJob({
|
await processNextPersistentPricingRefreshJob({
|
||||||
jobRepository: {
|
jobRepository: {
|
||||||
async claimNext() { return { id: 'job-4' }; },
|
async claimNext() {
|
||||||
async complete() { throw new Error('not expected'); },
|
return { id: "job-4" };
|
||||||
async fail(id, error) { calls.push([id, error]); },
|
},
|
||||||
|
async complete() {
|
||||||
|
throw new Error("not expected");
|
||||||
|
},
|
||||||
|
async fail(id, error) {
|
||||||
|
calls.push([id, error]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async pricingRefreshJob() {
|
||||||
|
return { status: "FAILED", error: "Provider unavailable." };
|
||||||
},
|
},
|
||||||
async pricingRefreshJob() { return { status: 'FAILED', error: 'Provider unavailable.' }; },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [['job-4', 'Provider unavailable.']]);
|
assert.deepEqual(calls, [["job-4", "Provider unavailable."]]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- image-storage:${IMAGE_STORAGE_VOLUME:-/var/lib/pokemon-inventory/images}
|
- image-storage:${IMAGE_STORAGE_VOLUME:-/var/lib/pokemon-inventory/images}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "pricing_observations" ADD COLUMN "external_identifier" text;
|
||||||
|
ALTER TABLE "pricing_observations" ADD COLUMN "search_query" text;
|
||||||
@@ -141,6 +141,13 @@
|
|||||||
"when": 1790236800000,
|
"when": 1790236800000,
|
||||||
"tag": "0019_pricing_job_retries",
|
"tag": "0019_pricing_job_retries",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790323200000,
|
||||||
|
"tag": "0020_pricing_job_mapping_context",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Status
|
# Implementation Status
|
||||||
|
|
||||||
Last updated: 2026-09-08
|
Last updated: 2026-09-09
|
||||||
|
|
||||||
## Resume Here
|
## Resume Here
|
||||||
|
|
||||||
@@ -118,44 +118,37 @@ docker context use orbstack
|
|||||||
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
||||||
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
||||||
|
|
||||||
Latest result: API, web, build, Storybook build, and Storybook interaction/accessibility checks pass locally. The isolated integration suite passes 20 tests, including acquisition, pricing, multi-item sales, audited sale voiding, and seller ownership persistence. The authenticated E2E workflow covers acquisition, image upload, pricing, listing publication, sale recording, dashboard loading, and audited sale voiding.
|
Verified locally: `npm test` passes 116 tests with no failures; worker tests, web tests, web build, Storybook build, and Storybook interaction/accessibility checks pass. The integration suite and authenticated E2E workflow are defined in CI; they require the isolated test database/Redis services and should not be described as locally verified unless those services are available.
|
||||||
|
|
||||||
## Next Milestone
|
## Next Milestone
|
||||||
|
|
||||||
Complete operational hardening around the implemented vertical slices.
|
Complete production configuration and operational maturity around the implemented vertical slices.
|
||||||
|
|
||||||
1. Add integration coverage for the latest acquisition, sales, pricing, and dashboard migrations.
|
1. Configure and validate an approved external pricing provider and provider-specific mapping data.
|
||||||
2. Document and verify CI execution of integration, E2E, and Storybook gates.
|
2. Schedule pricing-job retention cleanup and establish restore drills for PostgreSQL and image storage.
|
||||||
3. Add provider adapters and asynchronous pricing refresh jobs behind the existing pricing boundary.
|
3. Add richer exports/settings, broader accessibility/manual coverage, and any remaining integration edge cases.
|
||||||
|
|
||||||
Acceptance criteria: public DTOs remain private-data-safe, migrations apply to clean test databases, and CI runs the same meaningful gates used for local vertical-slice validation.
|
Acceptance criteria: production provider credentials are secret-managed, migrations and backups are exercised in the target environment, and CI runs integration, authenticated E2E, web, build, Storybook build, and Storybook interaction/accessibility gates.
|
||||||
|
|
||||||
## Overall Roadmap
|
## Overall Roadmap
|
||||||
|
|
||||||
Work through one bounded vertical slice at a time. The linked documentation remains authoritative when this roadmap is less detailed.
|
Work through one bounded vertical slice at a time. The linked documentation remains authoritative when this roadmap is less detailed.
|
||||||
|
|
||||||
1. Authentication hardening.
|
Completed vertical slices include authentication, listings and image prerequisites, acquisitions, sales and audited corrections, dashboard metrics/alerts, pricing observations and quality handling, provider mappings, durable pricing refresh jobs, public search/detail/image states, admin workflows, CI integration/E2E gates, production Docker packaging, rollback/health checks, PostgreSQL backup verification, and deployment smoke checks.
|
||||||
2. Listings and image prerequisites: keep Listing separate from Inventory Item; enforce publish requirements from [REQUIREMENTS.md](REQUIREMENTS.md).
|
|
||||||
3. Acquisition, pricing provider mappings, observations, valuations, and private/public DTO boundaries from [DATA-MODEL.md](DATA-MODEL.md) and [PRICING.md](PRICING.md).
|
Remaining roadmap areas are richer exports/settings, approved external provider credentials/adapters, scheduled operational cleanup, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation.
|
||||||
4. Sales workflow, sold-item locking, corrections, cost basis, and audit history.
|
|
||||||
5. SearchService, public catalog/search endpoints, filtering, pagination, and public product URLs.
|
|
||||||
6. Image storage abstraction, upload validation, processing worker, responsive variants, and alt-text editing.
|
|
||||||
7. Vue admin workflows and public catalog, following [DESIGN.md](DESIGN.md), [UX.md](UX.md), and [ACCESSIBILITY.md](ACCESSIBILITY.md).
|
|
||||||
8. Dashboard, bulk operations, exports, settings, workers, and provider adapters.
|
|
||||||
9. CI, Docker deployment completion, backups, end-to-end/accessibility coverage, and operational documentation.
|
|
||||||
|
|
||||||
## Known Gaps
|
## Known Gaps
|
||||||
|
|
||||||
- No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables.
|
- No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables.
|
||||||
- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains.
|
- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing remains.
|
||||||
- Concrete provider adapters, asynchronous pricing refresh transport, richer exports/settings, full integration CI services, and deployment/backup automation remain incomplete to varying degrees.
|
- The provider abstraction, fixture provider, HTTP provider adapter, Product mapping workflow, durable refresh queue, worker execution, polling contract, retry policy, and completed-job purge operation are implemented. Production readiness still requires approved external provider credentials and provider-specific mapping data.
|
||||||
- The pricing refresh worker now claims durable PostgreSQL jobs with row locking, persists completed/failed outcomes, and routes provider observations through the pricing service; configured external providers remain deployment work.
|
|
||||||
- Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline.
|
- Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline.
|
||||||
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
|
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
|
||||||
- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly.
|
- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly.
|
||||||
- Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries.
|
- Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries.
|
||||||
- Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff and an explicit completed-job purge operation for retention scheduling.
|
- Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff and an explicit completed-job purge operation for retention scheduling.
|
||||||
- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes.
|
- `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.
|
||||||
|
|
||||||
## Local Commands
|
## Local Commands
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ export function toAdminAcquisitionDto(acquisition) {
|
|||||||
createdAt: toIsoString(acquisition.createdAt),
|
createdAt: toIsoString(acquisition.createdAt),
|
||||||
updatedAt: toIsoString(acquisition.updatedAt),
|
updatedAt: toIsoString(acquisition.updatedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ export function toAdminDashboardDto(dashboard) {
|
|||||||
return {
|
return {
|
||||||
counts: dashboard.counts,
|
counts: dashboard.counts,
|
||||||
financials: dashboard.financials,
|
financials: dashboard.financials,
|
||||||
recentSales: dashboard.recentSales.map(({ sale, saleItem, inventoryItem, productName, netProceedsMinor, profitMinor }) => ({
|
recentSales: dashboard.recentSales.map(
|
||||||
id: sale.id,
|
({
|
||||||
productName: productName ?? inventoryItem.id,
|
sale,
|
||||||
saleDate: sale.saleDate,
|
saleItem,
|
||||||
soldPriceMinor: sale.soldPriceMinor,
|
inventoryItem,
|
||||||
netProceedsMinor,
|
productName,
|
||||||
profitMinor,
|
netProceedsMinor,
|
||||||
inventoryItemId: saleItem.inventoryItemId,
|
profitMinor,
|
||||||
})),
|
}) => ({
|
||||||
|
id: sale.id,
|
||||||
|
productName: productName ?? inventoryItem.id,
|
||||||
|
saleDate: sale.saleDate,
|
||||||
|
soldPriceMinor: sale.soldPriceMinor,
|
||||||
|
netProceedsMinor,
|
||||||
|
profitMinor,
|
||||||
|
inventoryItemId: saleItem.inventoryItemId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({
|
recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({
|
||||||
id: acquisition.id,
|
id: acquisition.id,
|
||||||
inventoryItemId: acquisition.inventoryItemId,
|
inventoryItemId: acquisition.inventoryItemId,
|
||||||
@@ -18,4 +27,4 @@ export function toAdminDashboardDto(dashboard) {
|
|||||||
costBasisMinor: acquisition.costBasisMinor ?? null,
|
costBasisMinor: acquisition.costBasisMinor ?? null,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"./public-listing": "./public-listing.js",
|
"./public-listing": "./public-listing.js",
|
||||||
"./public-inventory": "./public-inventory.js",
|
"./public-inventory": "./public-inventory.js",
|
||||||
"./public-product": "./public-product.js",
|
"./public-product": "./public-product.js",
|
||||||
"./public-seller": "./public-seller.js"
|
"./public-seller": "./public-seller.js",
|
||||||
,"./pricing": "./pricing.js",
|
"./pricing": "./pricing.js",
|
||||||
"./provider-mapping": "./provider-mapping.js"
|
"./provider-mapping": "./provider-mapping.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
function toIsoString(value) { return value instanceof Date ? value.toISOString() : value; }
|
function toIsoString(value) {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
export function toAdminPricingObservationDto(observation) {
|
export function toAdminPricingObservationDto(observation) {
|
||||||
return {
|
return {
|
||||||
@@ -28,7 +30,9 @@ export function toAdminMarketValuationDto({ valuation, observations }) {
|
|||||||
confidence: valuation.confidence,
|
confidence: valuation.confidence,
|
||||||
sourceCount: valuation.sourceCount,
|
sourceCount: valuation.sourceCount,
|
||||||
calculatedAt: toIsoString(valuation.calculatedAt),
|
calculatedAt: toIsoString(valuation.calculatedAt),
|
||||||
stale: Date.now() - new Date(valuation.calculatedAt).getTime() > 7 * 24 * 60 * 60 * 1000,
|
stale:
|
||||||
|
Date.now() - new Date(valuation.calculatedAt).getTime() >
|
||||||
|
7 * 24 * 60 * 60 * 1000,
|
||||||
observations: observations.map(toAdminPricingObservationDto),
|
observations: observations.map(toAdminPricingObservationDto),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ export function toAdminProviderMappingDto(mapping) {
|
|||||||
sourceUrl: mapping.sourceUrl ?? null,
|
sourceUrl: mapping.sourceUrl ?? null,
|
||||||
status: mapping.status,
|
status: mapping.status,
|
||||||
metadata: mapping.metadata ?? {},
|
metadata: mapping.metadata ?? {},
|
||||||
createdAt: mapping.createdAt instanceof Date ? mapping.createdAt.toISOString() : mapping.createdAt,
|
createdAt:
|
||||||
updatedAt: mapping.updatedAt instanceof Date ? mapping.updatedAt.toISOString() : mapping.updatedAt,
|
mapping.createdAt instanceof Date
|
||||||
|
? mapping.createdAt.toISOString()
|
||||||
|
: mapping.createdAt,
|
||||||
|
updatedAt:
|
||||||
|
mapping.updatedAt instanceof Date
|
||||||
|
? mapping.updatedAt.toISOString()
|
||||||
|
: mapping.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ export function toPublicListingDto(listing) {
|
|||||||
createdAt: toIsoString(listing.createdAt),
|
createdAt: toIsoString(listing.createdAt),
|
||||||
updatedAt: toIsoString(listing.updatedAt),
|
updatedAt: toIsoString(listing.updatedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ function toIsoString(value) {
|
|||||||
return value instanceof Date ? value.toISOString() : value;
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAdminSaleDto({ sale, saleItem, inventoryItem, saleItems = [saleItem], inventoryItems = [inventoryItem], netProceedsMinor, profitMinor, productName }) {
|
export function toAdminSaleDto({
|
||||||
|
sale,
|
||||||
|
saleItem,
|
||||||
|
inventoryItem,
|
||||||
|
saleItems = [saleItem],
|
||||||
|
inventoryItems = [inventoryItem],
|
||||||
|
netProceedsMinor,
|
||||||
|
profitMinor,
|
||||||
|
productName,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
id: sale.id,
|
id: sale.id,
|
||||||
sellerId: sale.sellerId,
|
sellerId: sale.sellerId,
|
||||||
@@ -40,8 +49,11 @@ export function toAdminSaleDto({ sale, saleItem, inventoryItem, saleItems = [sal
|
|||||||
id: inventoryItem.id,
|
id: inventoryItem.id,
|
||||||
status: inventoryItem.status,
|
status: inventoryItem.status,
|
||||||
},
|
},
|
||||||
inventoryItems: inventoryItems.map((item) => ({ id: item.id, status: item.status })),
|
inventoryItems: inventoryItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
createdAt: toIsoString(sale.createdAt),
|
createdAt: toIsoString(sale.createdAt),
|
||||||
updatedAt: toIsoString(sale.updatedAt),
|
updatedAt: toIsoString(sale.updatedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createAcquisitionSchema = z.object({
|
export const createAcquisitionSchema = z
|
||||||
inventoryItemId: z.uuid(),
|
.object({
|
||||||
method: z.string().trim().min(1).max(100),
|
inventoryItemId: z.uuid(),
|
||||||
costMinor: z.int().nonnegative(),
|
method: z.string().trim().min(1).max(100),
|
||||||
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(),
|
costMinor: z.int().nonnegative(),
|
||||||
date: z.string().trim().max(10).optional(),
|
currency: z
|
||||||
source: z.string().trim().max(255).optional(),
|
.string()
|
||||||
location: z.string().trim().max(255).optional(),
|
.trim()
|
||||||
shippingMinor: z.int().nonnegative().optional(),
|
.regex(/^[A-Za-z]{3}$/)
|
||||||
taxMinor: z.int().nonnegative().optional(),
|
.optional(),
|
||||||
otherFeesMinor: z.int().nonnegative().optional(),
|
date: z.string().trim().max(10).optional(),
|
||||||
estimatedTradeValueMinor: z.int().nonnegative().optional(),
|
source: z.string().trim().max(255).optional(),
|
||||||
notes: z.string().trim().max(10000).optional(),
|
location: z.string().trim().max(255).optional(),
|
||||||
}).strict();
|
shippingMinor: z.int().nonnegative().optional(),
|
||||||
|
taxMinor: z.int().nonnegative().optional(),
|
||||||
|
otherFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
estimatedTradeValueMinor: z.int().nonnegative().optional(),
|
||||||
|
notes: z.string().trim().max(10000).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
"./image": "./image.js",
|
"./image": "./image.js",
|
||||||
"./listing": "./listing.js",
|
"./listing": "./listing.js",
|
||||||
"./sale": "./sale.js",
|
"./sale": "./sale.js",
|
||||||
"./product": "./product.js"
|
"./product": "./product.js",
|
||||||
,"./pricing": "./pricing.js",
|
"./pricing": "./pricing.js",
|
||||||
"./provider-mapping": "./provider-mapping.js"
|
"./provider-mapping": "./provider-mapping.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.1.8"
|
"zod": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createPricingObservationSchema = z.object({
|
export const createPricingObservationSchema = z
|
||||||
productId: z.uuid(),
|
.object({
|
||||||
inventoryItemId: z.uuid(),
|
productId: z.uuid(),
|
||||||
providerId: z.string().trim().min(1).max(100),
|
inventoryItemId: z.uuid(),
|
||||||
priceMinor: z.int().positive(),
|
providerId: z.string().trim().min(1).max(100),
|
||||||
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(),
|
priceMinor: z.int().positive(),
|
||||||
sourceIdentifier: z.string().trim().min(1).max(255),
|
currency: z
|
||||||
conditionId: z.uuid(),
|
.string()
|
||||||
sourceUrl: z.string().trim().url().optional(),
|
.trim()
|
||||||
qualityScore: z.int().min(0).max(100).optional(),
|
.regex(/^[A-Za-z]{3}$/)
|
||||||
}).strict();
|
.optional(),
|
||||||
|
sourceIdentifier: z.string().trim().min(1).max(255),
|
||||||
|
conditionId: z.uuid(),
|
||||||
|
sourceUrl: z.string().trim().url().optional(),
|
||||||
|
qualityScore: z.int().min(0).max(100).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const createMarketValueOverrideSchema = z.object({
|
export const createMarketValueOverrideSchema = z
|
||||||
valueMinor: z.int().nonnegative(),
|
.object({
|
||||||
currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(),
|
valueMinor: z.int().nonnegative(),
|
||||||
reason: z.string().trim().min(3).max(1000),
|
currency: z
|
||||||
}).strict();
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[A-Za-z]{3}$/)
|
||||||
|
.optional(),
|
||||||
|
reason: z.string().trim().min(3).max(1000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createProviderMappingSchema = z.object({
|
export const createProviderMappingSchema = z
|
||||||
providerId: z.string().trim().min(1).max(100),
|
.object({
|
||||||
mappingType: z.enum(['PRODUCT', 'SEARCH']),
|
providerId: z.string().trim().min(1).max(100),
|
||||||
externalIdentifier: z.string().trim().max(255).optional(),
|
mappingType: z.enum(["PRODUCT", "SEARCH"]),
|
||||||
searchQuery: z.string().trim().max(1000).optional(),
|
externalIdentifier: z.string().trim().max(255).optional(),
|
||||||
sourceUrl: z.string().trim().url().optional(),
|
searchQuery: z.string().trim().max(1000).optional(),
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
sourceUrl: z.string().trim().url().optional(),
|
||||||
}).strict();
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|||||||
+40
-34
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
const currencyCode = /^[A-Za-z]{3}$/;
|
const currencyCode = /^[A-Za-z]{3}$/;
|
||||||
@@ -8,38 +8,44 @@ const saleItemSchema = z.object({
|
|||||||
costBasisMinor: z.int().nonnegative(),
|
costBasisMinor: z.int().nonnegative(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createSaleSchema = z.object({
|
export const createSaleSchema = z
|
||||||
inventoryItemId: z.uuid(),
|
.object({
|
||||||
costBasisMinor: z.int().nonnegative(),
|
inventoryItemId: z.uuid(),
|
||||||
saleDate: z.string().trim().regex(isoDate),
|
costBasisMinor: z.int().nonnegative(),
|
||||||
saleLocation: z.string().trim().max(255).optional(),
|
saleDate: z.string().trim().regex(isoDate),
|
||||||
soldPriceMinor: z.int().positive(),
|
saleLocation: z.string().trim().max(255).optional(),
|
||||||
currency: z.string().trim().regex(currencyCode).optional(),
|
soldPriceMinor: z.int().positive(),
|
||||||
platformFeesMinor: z.int().nonnegative().optional(),
|
currency: z.string().trim().regex(currencyCode).optional(),
|
||||||
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
platformFeesMinor: z.int().nonnegative().optional(),
|
||||||
shippingCostMinor: z.int().nonnegative().optional(),
|
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
||||||
taxesMinor: z.int().nonnegative().optional(),
|
shippingCostMinor: z.int().nonnegative().optional(),
|
||||||
otherFeesMinor: z.int().nonnegative().optional(),
|
taxesMinor: z.int().nonnegative().optional(),
|
||||||
buyerName: z.string().trim().max(255).optional(),
|
otherFeesMinor: z.int().nonnegative().optional(),
|
||||||
buyerContact: z.string().trim().max(255).optional(),
|
buyerName: z.string().trim().max(255).optional(),
|
||||||
notes: z.string().trim().max(10000).optional(),
|
buyerContact: z.string().trim().max(255).optional(),
|
||||||
}).strict();
|
notes: z.string().trim().max(10000).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const createMultiItemSaleSchema = z.object({
|
export const createMultiItemSaleSchema = z
|
||||||
items: z.array(saleItemSchema).min(2),
|
.object({
|
||||||
saleDate: z.string().trim().regex(isoDate),
|
items: z.array(saleItemSchema).min(2),
|
||||||
saleLocation: z.string().trim().max(255).optional(),
|
saleDate: z.string().trim().regex(isoDate),
|
||||||
currency: z.string().trim().regex(currencyCode).optional(),
|
saleLocation: z.string().trim().max(255).optional(),
|
||||||
platformFeesMinor: z.int().nonnegative().optional(),
|
currency: z.string().trim().regex(currencyCode).optional(),
|
||||||
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
platformFeesMinor: z.int().nonnegative().optional(),
|
||||||
shippingCostMinor: z.int().nonnegative().optional(),
|
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
||||||
taxesMinor: z.int().nonnegative().optional(),
|
shippingCostMinor: z.int().nonnegative().optional(),
|
||||||
otherFeesMinor: z.int().nonnegative().optional(),
|
taxesMinor: z.int().nonnegative().optional(),
|
||||||
buyerName: z.string().trim().max(255).optional(),
|
otherFeesMinor: z.int().nonnegative().optional(),
|
||||||
buyerContact: z.string().trim().max(255).optional(),
|
buyerName: z.string().trim().max(255).optional(),
|
||||||
notes: z.string().trim().max(10000).optional(),
|
buyerContact: z.string().trim().max(255).optional(),
|
||||||
}).strict();
|
notes: z.string().trim().max(10000).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const voidSaleSchema = z.object({
|
export const voidSaleSchema = z
|
||||||
reason: z.string().trim().min(3).max(1000),
|
.object({
|
||||||
}).strict();
|
reason: z.string().trim().min(3).max(1000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|||||||
Reference in New Issue
Block a user