feat: reconcile application vertical slices

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