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
+44 -43
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,87 +35,87 @@ export function buildApp(dependencies) {
return reply.code(statusCode).send({
error: {
code: error.code ?? 'INTERNAL_SERVER_ERROR',
code: error.code ?? "INTERNAL_SERVER_ERROR",
message,
},
});
});
app.get('/health', async () => ({ data: { status: 'ok' } }));
app.get("/health", async () => ({ data: { status: "ok" } }));
app.register(cookie);
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
app.register(registerAuthRoutes, {
...dependencies,
prefix: '/api/v1/auth',
prefix: "/api/v1/auth",
});
app.register(registerAdminProductRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminInventoryRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminLookupRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminImageRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminListingRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminSaleRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminAcquisitionRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminDashboardRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminPricingRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerAdminProviderMappingRoutes, {
...dependencies,
prefix: '/api/v1/admin',
prefix: "/api/v1/admin",
});
app.register(registerPublicSearchRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicProductRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicListingRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicInventoryRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicImageRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicSellerRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
app.register(registerPublicLookupRoutes, {
...dependencies,
prefix: '/api/v1',
prefix: "/api/v1",
});
return app;
}
}
+120 -72
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,42 +153,65 @@ async function startServer() {
}),
catalogLookupRepository,
publicImageService,
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
publicInventoryService: createPublicInventoryService({
inventoryItemRepository,
publicImageService,
}),
inventoryImageService: createInventoryImageService({
imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository,
inventoryImageRepository,
}),
listingService: createListingService({ listingRepository, inventoryImageRepository }),
listingService: createListingService({
listingRepository,
inventoryImageRepository,
}),
saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }),
dashboardService: createDashboardService({
productService: createProductService({ productRepository }),
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }),
listingService: createListingService({ listingRepository, inventoryImageRepository }),
inventoryItemService: createInventoryItemService({
conditionRepository,
inventoryItemRepository,
productRepository,
}),
listingService: createListingService({
listingRepository,
inventoryImageRepository,
}),
saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }),
pricingRepository,
pricingRefreshJobRepository,
}),
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }),
providerMappingService: createProviderMappingService({ providerMappingRepository }),
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
pricingService: createPricingService({
pricingRepository,
pricingRefreshJobRepository,
providerMappingRepository,
pricingProviderRegistry: pricingProviders,
}),
providerMappingService: createProviderMappingService({
providerMappingRepository,
}),
publicListingService: createPublicListingService({
listingRepository,
publicImageService,
}),
productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }),
publicSellerService: createPublicSellerService({ sellerRepository }),
});
app.addHook('onClose', async () => {
app.addHook("onClose", async () => {
await database.close();
await redis.close();
});
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) });
await app.listen({ host: "0.0.0.0", port: parsePort(process.env.API_PORT) });
}
startServer().catch((error) => {
console.error(error);
process.exitCode = 1;
});
});
+325 -264
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,11 +34,14 @@ export function createAcquisitionRepository(db) {
return db
.select({ acquisition: acquisitions })
.from(acquisitions)
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, acquisitions.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(acquisitions.createdAt))
.then((rows) => rows.map(({ acquisition }) => acquisition));
},
};
}
}
@@ -1,42 +1,64 @@
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition';
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition';
import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminAcquisitionRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
acquisitionService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/acquisitions', async (request) => ({
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto),
app.get("/acquisitions", async (request) => ({
data: (
await acquisitionService.list({ sellerId: request.admin.sellerId })
).map(toAdminAcquisitionDto),
}));
app.post('/acquisitions', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/acquisitions", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createAcquisitionSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId });
const acquisition = await acquisitionService.create({
...result.data,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
});
}
}
@@ -1,13 +1,23 @@
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import {
createAcquisition,
calculateAcquisitionCostBasis,
} from "../domain/acquisition.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createAcquisitionService({ acquisitionRepository }) {
return {
async create({ sellerId, ...input }) {
const acquisition = createAcquisition(input);
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId });
if (result.status === 'NOT_FOUND') {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await acquisitionRepository.createForSeller({
acquisition,
sellerId,
});
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
}
return {
@@ -1,20 +1,33 @@
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard';
import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
export async function registerAdminDashboardRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
dashboardService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/dashboard', async (request) => ({
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
app.get("/dashboard", async (request) => ({
data: toAdminDashboardDto(
await dashboardService.get({ sellerId: request.admin.sellerId }),
),
}));
}
}
@@ -1,7 +1,23 @@
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) {
export function createDashboardService({
productService,
inventoryItemService,
listingService,
saleService,
acquisitionService,
pricingRepository,
pricingRefreshJobRepository,
}) {
return {
async get({ sellerId }) {
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([
const [
products,
inventory,
listings,
sales,
acquisitions,
valuations,
failedPricingJobs,
] = await Promise.all([
productService.listBySellerId(sellerId),
inventoryItemService.listBySellerId(sellerId),
listingService.list({ sellerId }),
@@ -11,36 +27,51 @@ export function createDashboardService({ productService, inventoryItemService, l
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
]);
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation]));
const activeListings = listings.filter(({ status }) =>
["AVAILABLE", "PENDING"].includes(status),
);
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
const valuationByItem = new Map(
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
);
const staleThreshold = 7 * 24 * 60 * 60 * 1000;
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
const total = (values, field) =>
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
return {
counts: {
products: products.length,
inventory: inventory.length,
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
.length,
soldInventory: inventory.filter(({ status }) => status === "SOLD")
.length,
availableListings: activeListings.length,
sales: completedSales.length,
acquisitions: acquisitions.length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length,
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1)
.length,
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
.length,
stalePricing: valuations.filter(
({ calculatedAt }) =>
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
).length,
failedPricingJobs,
},
financials: {
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
totalProfitMinor: total(completedSales, 'profitMinor'),
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'),
totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
totalProfitMinor: total(completedSales, "profitMinor"),
totalMarketValueMinor: total(
[...valuationByItem.values()],
"valueMinor",
),
},
recentSales: completedSales.slice(0, 5),
recentAcquisitions: acquisitions.slice(0, 5),
};
},
};
}
}
@@ -1,11 +1,13 @@
export async function registerPublicLookupRoutes(app, options) {
const { catalogLookupRepository } = options;
app.get('/public/lookups/categories', async () => ({
app.get("/public/lookups/categories", async () => ({
data: await catalogLookupRepository.listCategories(),
}));
app.get('/public/lookups/conditions', async () => ({
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })),
app.get("/public/lookups/conditions", async () => ({
data: (await catalogLookupRepository.listConditions()).map(
({ id, name, code }) => ({ id, name, code }),
),
}));
}
}
@@ -1,4 +1,14 @@
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql,
} from "drizzle-orm";
import {
conditions,
@@ -7,12 +17,15 @@ import {
marketValueOverrides,
marketValuations,
products,
} from '../../../infrastructure/database/schema.js';
} from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) {
return {
async create(listing) {
const [createdListing] = await db.insert(listings).values(listing).returning();
const [createdListing] = await db
.insert(listings)
.values(listing)
.returning();
return createdListing;
},
@@ -28,18 +41,28 @@ export function createListingRepository(db) {
const [result] = await db
.select({ listing: listings, inventoryStatus: inventoryItems.status })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
.where(
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
);
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
return result
? { ...result.listing, inventoryStatus: result.inventoryStatus }
: null;
},
async listBySellerId(sellerId) {
const results = await db
.select({ listing: listings })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(listings.createdAt));
@@ -49,7 +72,7 @@ export function createListingRepository(db) {
async searchPublic(filters = {}) {
const {
text = '',
text = "",
category,
conditionId,
minPriceMinor,
@@ -57,35 +80,40 @@ export function createListingRepository(db) {
page = 1,
limit = 20,
includeSold = false,
sort = 'recent',
sort = "recent",
} = filters;
const conditions = [
inArray(
listings.status,
includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'],
includeSold
? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"],
),
inArray(
inventoryItems.status,
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
),
inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']),
];
if (typeof category === 'string' && category.trim().length > 0) {
if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category));
}
if (typeof conditionId === 'string' && conditionId.trim().length > 0) {
if (typeof conditionId === "string" && conditionId.trim().length > 0) {
conditions.push(eq(inventoryItems.conditionId, conditionId));
}
if (typeof minPriceMinor === 'number') {
if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
}
if (typeof maxPriceMinor === 'number') {
if (typeof maxPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
}
if (typeof text === 'string' && text.trim().length > 0) {
const searchTerms = text.split(' ').filter(Boolean);
if (typeof text === "string" && text.trim().length > 0) {
const searchTerms = text.split(" ").filter(Boolean);
if (searchTerms.length > 0) {
conditions.push(
@@ -105,28 +133,35 @@ export function createListingRepository(db) {
}
const offset = (page - 1) * limit;
const sortOrder = sort === 'price_low'
? listings.askingPriceMinor
: sort === 'price_high'
? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const sortOrder =
sort === "price_low"
? listings.askingPriceMinor
: sort === "price_high"
? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const [countResult, results] = await Promise.all([
db
.select({ total: count() })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions)),
db
.select({ listing: listings })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
.select({ listing: listings })
.from(listings)
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
]);
return {
@@ -139,22 +174,40 @@ export function createListingRepository(db) {
const [result] = await db
.select({ listing: listings, condition: conditions })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.where(and(
eq(listings.id, listingId),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryItems.status, 'ACTIVE'),
));
.where(
and(
eq(listings.id, listingId),
inArray(listings.status, ["AVAILABLE", "PENDING"]),
eq(inventoryItems.status, "ACTIVE"),
),
);
if (!result) return null;
const [override] = await db.select().from(marketValueOverrides)
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId))
.orderBy(desc(marketValueOverrides.createdAt)).limit(1);
const [valuation] = await db.select().from(marketValuations)
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId))
.orderBy(desc(marketValuations.calculatedAt)).limit(1);
const [override] = await db
.select()
.from(marketValueOverrides)
.where(
eq(
marketValueOverrides.inventoryItemId,
result.listing.inventoryItemId,
),
)
.orderBy(desc(marketValueOverrides.createdAt))
.limit(1);
const [valuation] = await db
.select()
.from(marketValuations)
.where(
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
)
.orderBy(desc(marketValuations.calculatedAt))
.limit(1);
return {
...result.listing,
conditionName: result.condition.name,
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`);
}
@@ -19,28 +19,30 @@ export function createPricingObservation({
inventoryItemId,
providerId,
priceMinor,
currency = 'USD',
currency = "USD",
sourceIdentifier,
conditionId,
sourceUrl,
qualityScore,
}) {
return {
productId: requiredText(productId, 'Product ID'),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
providerId: requiredText(providerId, 'Provider ID'),
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
currency: requiredText(currency, 'Currency'),
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
conditionId: requiredText(conditionId, 'Condition ID'),
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
productId: requiredText(productId, "Product ID"),
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
providerId: requiredText(providerId, "Provider ID"),
priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
currency: requiredText(currency, "Currency"),
sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
conditionId: requiredText(conditionId, "Condition ID"),
...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
? { sourceUrl: sourceUrl.trim() }
: {}),
...(typeof qualityScore === "number" ? { qualityScore } : {}),
};
}
export function calculateMedianPriceMinor(prices) {
if (!Array.isArray(prices) || prices.length === 0) {
throw new Error('At least one price is required.');
throw new Error("At least one price is required.");
}
const sorted = [...prices].sort((left, right) => left - right);
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
if (!Array.isArray(prices) || prices.length < 4) return new Set();
const sorted = [...prices].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const median =
sorted.length % 2
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const lowerFence = median / 3;
const upperFence = median * 3;
return new Set(prices.filter((price) => price < lowerFence || price > upperFence));
return new Set(
prices.filter((price) => price < lowerFence || price > upperFence),
);
}
@@ -1,18 +1,20 @@
import { createPricingProvider } from './pricing-provider.js';
import { createPricingProvider } from "./pricing-provider.js";
export function createFixturePricingProvider() {
return createPricingProvider({
id: 'fixture-provider',
id: "fixture-provider",
async observe({ productId, inventoryItemId, conditionId }) {
return [{
productId,
inventoryItemId,
conditionId,
priceMinor: 2500,
currency: 'USD',
sourceIdentifier: `fixture-${inventoryItemId}`,
qualityScore: 50,
}];
return [
{
productId,
inventoryItemId,
conditionId,
priceMinor: 2500,
currency: "USD",
sourceIdentifier: `fixture-${inventoryItemId}`,
qualityScore: 50,
},
];
},
});
}
@@ -1,10 +1,10 @@
export function createPricingProvider({ id, observe }) {
if (typeof id !== 'string' || id.trim().length === 0) {
throw new Error('Pricing provider id is required.');
if (typeof id !== "string" || id.trim().length === 0) {
throw new Error("Pricing provider id is required.");
}
if (typeof observe !== 'function') {
throw new TypeError('Pricing provider observe function is required.');
if (typeof observe !== "function") {
throw new TypeError("Pricing provider observe function is required.");
}
return Object.freeze({
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
async observe(input) {
const observations = await observe(input);
if (!Array.isArray(observations)) {
throw new Error(`Pricing provider ${id} must return an observation array.`);
throw new Error(
`Pricing provider ${id} must return an observation array.`,
);
}
return observations;
},
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
}
export function createPricingProviderRegistry(providers = []) {
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
const providerMap = new Map(
providers.map((provider) => [provider.id, provider]),
);
return {
get(providerId) {
@@ -1,95 +1,181 @@
import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm';
import { and, asc, desc, eq, isNull, lte, or } from "drizzle-orm";
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
import { pricingRefreshJobs } from "../../../infrastructure/database/schema.js";
export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3;
return {
async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
const [created] = await db
.insert(pricingRefreshJobs)
.values(job)
.returning();
return created;
},
async claimNext() {
return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs)
.where(and(
eq(pricingRefreshJobs.status, 'PENDING'),
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
))
const [job] = await transaction
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.status, "PENDING"),
or(
isNull(pricingRefreshJobs.nextAttemptAt),
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
),
),
)
.orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1)
.for('update', { skipLocked: true });
.for("update", { skipLocked: true });
if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs)
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
const [claimed] = await transaction
.update(pricingRefreshJobs)
.set({
status: "PROCESSING",
attemptCount: pricingRefreshJobs.attemptCount,
updatedAt: new Date(),
error: null,
nextAttemptAt: null,
})
.where(
and(
eq(pricingRefreshJobs.id, job.id),
eq(pricingRefreshJobs.status, "PENDING"),
),
)
.returning();
return claimed ?? null;
});
},
async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId)));
const [job] = await db
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
),
);
return job ?? null;
},
async countFailedForSeller(sellerId) {
const rows = await db.select({ id: pricingRefreshJobs.id })
const rows = await db
.select({ id: pricingRefreshJobs.id })
.from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')));
.where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
),
);
return rows.length;
},
async listForSeller(sellerId) {
return db.select().from(pricingRefreshJobs)
return db
.select()
.from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.sellerId, sellerId))
.orderBy(desc(pricingRefreshJobs.createdAt));
},
async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs)
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
const [job] = await db
.update(pricingRefreshJobs)
.set({
status: "PENDING",
error: null,
observations: [],
valuation: null,
nextAttemptAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
),
)
.returning();
return job ?? null;
},
async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs)
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
const [completed] = await db
.update(pricingRefreshJobs)
.set({
status: "COMPLETED",
observations,
valuation,
error: null,
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning();
return completed ?? null;
},
async fail(id, message) {
return db.transaction(async (transaction) => {
const [current] = await transaction.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
const [current] = await transaction
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
);
if (!current) return null;
const attemptCount = current.attemptCount + 1;
const retryable = attemptCount < maxAttempts;
const [updated] = await transaction.update(pricingRefreshJobs)
const [updated] = await transaction
.update(pricingRefreshJobs)
.set({
status: retryable ? 'PENDING' : 'FAILED',
status: retryable ? "PENDING" : "FAILED",
error: message,
attemptCount,
updatedAt: new Date(),
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null,
nextAttemptAt: retryable
? new Date(Date.now() + 2 ** attemptCount * 1000)
: null,
})
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning();
return updated ?? null;
});
},
async purgeCompletedBefore(sellerId, cutoff) {
const deleted = await db.delete(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
const deleted = await db
.delete(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "COMPLETED"),
lte(pricingRefreshJobs.updatedAt, cutoff),
),
)
.returning({ id: pricingRefreshJobs.id });
return deleted.length;
},
};
}
}
@@ -1,7 +1,14 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, desc, eq, inArray } from "drizzle-orm";
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js';
import { identifyOutlierPrices } from '../domain/pricing-observation.js';
import {
inventoryItems,
marketValueOverrides,
marketValuations,
pricingObservations,
products,
securityAuditEvents,
} from "../../../infrastructure/database/schema.js";
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
export function createPricingRepository(db) {
return {
@@ -10,77 +17,165 @@ export function createPricingRepository(db) {
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' };
const [created] = await db.insert(pricingObservations).values(observation).returning();
return { status: 'CREATED', observation: created };
.where(
and(
eq(inventoryItems.id, observation.inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem || ownedItem.productId !== observation.productId)
return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingObservations)
.values(observation)
.returning();
return { status: "CREATED", observation: created };
},
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) {
async calculateForSeller({
inventoryItemId,
sellerId,
strategy = "MEDIAN",
}) {
const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const observations = await db.select().from(pricingObservations)
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false)))
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
eq(pricingObservations.isOutlier, false),
),
)
.orderBy(desc(pricingObservations.observedAt));
if (!observations.length) return { status: 'NO_DATA' };
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right);
if (!observations.length) return { status: "NO_DATA" };
const prices = observations
.map(({ priceMinor }) => priceMinor)
.sort((left, right) => left - right);
const middle = Math.floor(prices.length / 2);
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2);
const [valuation] = await db.insert(marketValuations).values({
inventoryItemId,
valueMinor,
currency: 'USD',
strategy,
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM',
sourceCount: observations.length,
}).returning();
return { status: 'CALCULATED', valuation, observations };
const valueMinor =
prices.length % 2
? prices[middle]
: Math.round((prices[middle - 1] + prices[middle]) / 2);
const [valuation] = await db
.insert(marketValuations)
.values({
inventoryItemId,
valueMinor,
currency: "USD",
strategy,
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
sourceCount: observations.length,
})
.returning();
return { status: "CALCULATED", valuation, observations };
},
async listLatestValuationsForSeller(sellerId) {
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id })
const rows = await db
.select({
valuation: marketValuations,
inventoryItemId: inventoryItems.id,
})
.from(marketValuations)
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, marketValuations.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(marketValuations.calculatedAt));
const latest = new Map();
for (const row of rows) {
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation);
if (!latest.has(row.inventoryItemId))
latest.set(row.inventoryItemId, row.valuation);
}
return [...latest.values()];
},
async markOutliersForSeller({ inventoryItemId, sellerId }) {
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems)
const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
const prices = observations.map(({ priceMinor }) => priceMinor);
const outlierPrices = identifyOutlierPrices(prices);
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
await db
.update(pricingObservations)
.set({ isOutlier: false, outlierReason: null })
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
if (outlierPrices.size) {
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
await db
.update(pricingObservations)
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
inArray(pricingObservations.priceMinor, [...outlierPrices]),
),
);
}
return { status: 'UPDATED' };
return { status: "UPDATED" };
},
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) {
async createOverride({
inventoryItemId,
sellerId,
actorUserId,
valueMinor,
currency,
reason,
}) {
return db.transaction(async (transaction) => {
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems)
const [ownedItem] = await transaction
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning();
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } });
return { status: 'CREATED', override };
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const [override] = await transaction
.insert(marketValueOverrides)
.values({
inventoryItemId,
valueMinor,
currency,
reason,
createdBy: actorUserId,
})
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: "MARKET_VALUE_OVERRIDDEN",
metadata: { inventoryItemId, overrideId: override.id, reason },
});
return { status: "CREATED", override };
});
},
};
}
}
@@ -1,25 +1,42 @@
import { and, eq } from 'drizzle-orm';
import { and, eq } from "drizzle-orm";
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js';
import {
pricingProviderMappings,
products,
} from "../../../infrastructure/database/schema.js";
export function createProviderMappingRepository(db) {
return {
async createForSeller(mapping, sellerId) {
const [ownedProduct] = await db.select({ id: products.id }).from(products)
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId)));
if (!ownedProduct) return { status: 'NOT_FOUND' };
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning();
return { status: 'CREATED', mapping: created };
const [ownedProduct] = await db
.select({ id: products.id })
.from(products)
.where(
and(
eq(products.id, mapping.productId),
eq(products.sellerId, sellerId),
),
);
if (!ownedProduct) return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingProviderMappings)
.values(mapping)
.returning();
return { status: "CREATED", mapping: created };
},
async listForSeller(productId, sellerId) {
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
return db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
))
.where(
and(
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, "ACTIVE"),
),
)
.then((rows) => rows.map(({ mapping }) => mapping));
},
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
const conditions = [
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
eq(pricingProviderMappings.status, "ACTIVE"),
];
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
if (providerId)
conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(...conditions));
return row?.mapping ?? null;
},
async disableForSeller(id, sellerId) {
const [mapping] = await db.select().from(pricingProviderMappings)
const [mapping] = await db
.select()
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(
eq(pricingProviderMappings.id, id),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
));
if (!mapping) return { status: 'NOT_FOUND' };
const [updated] = await db.update(pricingProviderMappings)
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(pricingProviderMappings.id, id),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, "ACTIVE"),
),
);
if (!mapping) return { status: "NOT_FOUND" };
const [updated] = await db
.update(pricingProviderMappings)
.set({
status: "DISABLED",
disabledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pricingProviderMappings.id, id))
.returning();
return { status: 'DISABLED', mapping: updated };
return { status: "DISABLED", mapping: updated };
},
};
}
@@ -1,48 +1,85 @@
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
import {
toAdminMarketValuationDto,
toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing";
import {
createMarketValueOverrideSchema,
createPricingObservationSchema,
} from "@pokemon-inventory/validation/pricing";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminPricingRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
pricingService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.post('/pricing/observations', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/pricing/observations", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createPricingObservationSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId });
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) });
const observation = await pricingService.addObservation({
...result.data,
sellerId: request.admin.sellerId,
});
return reply
.code(201)
.send({ data: toAdminPricingObservationDto(observation) });
});
app.post('/inventory/:id/valuation', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
app.post("/inventory/:id/valuation", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const valuation = await pricingService.calculate({
sellerId: request.admin.sellerId,
inventoryItemId: request.params.id,
});
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
});
app.post('/inventory/:id/valuation/override', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/inventory/:id/valuation/override", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createMarketValueOverrideSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const override = await pricingService.override({
@@ -54,8 +91,11 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override });
});
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const { providerId, productId, conditionId } = request.body ?? {};
const job = await pricingService.enqueueRefresh({
sellerId: request.admin.sellerId,
@@ -64,11 +104,16 @@ export async function registerAdminPricingRoutes(app, options) {
productId,
conditionId,
});
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
app.get('/jobs/:id', async (request) => {
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
app.get("/jobs/:id", async (request) => {
const job = await pricingService.getJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return {
data: {
id: job.id,
@@ -81,16 +126,31 @@ export async function registerAdminPricingRoutes(app, options) {
};
});
app.get('/jobs', async (request) => ({
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId,
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
app.get("/jobs", async (request) => ({
data: (
await pricingService.listJobs({ sellerId: request.admin.sellerId })
).map((job) => ({
id: job.id,
status: job.status,
providerId: job.providerId,
inventoryItemId: job.inventoryItemId,
error: job.error ?? null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
})),
}));
app.post('/jobs/:id/retry', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
app.post("/jobs/:id/retry", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const job = await pricingService.retryJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
}
}
@@ -1,47 +1,79 @@
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping';
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping';
import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminProviderMappingRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
providerMappingService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/products/:id/provider-mappings', async (request) => ({
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto),
app.get("/products/:id/provider-mappings", async (request) => ({
data: (
await providerMappingService.list({
productId: request.params.id,
sellerId: request.admin.sellerId,
})
).map(toAdminProviderMappingDto),
}));
app.post('/products/:id/provider-mappings', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/products/:id/provider-mappings", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createProviderMappingSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId });
const mapping = await providerMappingService.create({
...result.data,
productId: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
});
app.post('/provider-mappings/:id/disable', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId });
app.post("/provider-mappings/:id/disable", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const mapping = await providerMappingService.disable({
id: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
});
}
@@ -1,66 +1,184 @@
import { createPricingObservation } from '../domain/pricing-observation.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createPricingObservation } from "../domain/pricing-observation.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
export function createPricingService({
pricingRepository,
pricingProviderRegistry,
pricingRefreshJobRepository,
providerMappingRepository,
}) {
return {
async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input);
const result = await pricingRepository.addObservationForSeller({ observation, sellerId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await pricingRepository.addObservationForSeller({
observation,
sellerId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.observation;
},
async markOutliers({ sellerId, inventoryItemId }) {
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await pricingRepository.markOutliersForSeller({
sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result;
},
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) {
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400);
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400);
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
async override({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency = "USD",
reason,
}) {
if (!Number.isInteger(valueMinor) || valueMinor < 0)
throw createApplicationError(
"INVALID_OVERRIDE_VALUE",
"Override value must be a non-negative integer minor unit.",
400,
);
if (typeof reason !== "string" || reason.trim().length < 3)
throw createApplicationError(
"OVERRIDE_REASON_REQUIRED",
"An override reason is required.",
400,
);
const result = await pricingRepository.createOverride({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency: currency.toUpperCase(),
reason: reason.trim(),
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.override;
},
async calculate({ sellerId, inventoryItemId }) {
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
await pricingRepository.markOutliersForSeller({
sellerId,
inventoryItemId,
});
const result = await pricingRepository.calculateForSeller({
sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
if (result.status === "NO_DATA")
throw createApplicationError(
"PRICING_DATA_UNAVAILABLE",
"No pricing observations are available.",
404,
);
return result;
},
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
async refresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
const provider = pricingProviderRegistry?.get(providerId);
if (!provider) {
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503);
throw createApplicationError(
"PRICING_PROVIDER_UNAVAILABLE",
"Pricing provider is not configured.",
503,
);
}
let providerObservations;
try {
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
providerObservations = await provider.observe({
inventoryItemId,
productId,
conditionId,
});
} catch (error) {
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
throw createApplicationError(
"PRICING_PROVIDER_FAILED",
error.message,
502,
);
}
const observations = [];
for (const input of providerObservations) {
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId }));
observations.push(
await this.addObservation({
sellerId,
...input,
providerId: provider.id,
inventoryItemId,
productId,
conditionId,
}),
);
}
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) };
return {
observations,
valuation: await this.calculate({ sellerId, inventoryItemId }),
};
},
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId);
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409);
async enqueueRefresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
if (!pricingRefreshJobRepository)
throw createApplicationError(
"PRICING_QUEUE_UNAVAILABLE",
"Pricing refresh queue is unavailable.",
503,
);
const mapping = await providerMappingRepository?.findActiveForSeller(
productId,
sellerId,
providerId,
);
if (!mapping)
throw createApplicationError(
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
"No active pricing provider mapping exists for this product.",
409,
);
const job = await pricingRefreshJobRepository.create({
sellerId,
inventoryItemId,
providerId: mapping.providerId,
externalIdentifier: mapping.externalIdentifier,
searchQuery: mapping.searchQuery,
productId,
conditionId,
});
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
},
async getJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId);
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
const job = await pricingRefreshJobRepository?.findByIdForSeller(
jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_FOUND",
"Pricing job was not found.",
404,
);
return job;
},
@@ -78,9 +204,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
},
async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
const job = await pricingRefreshJobRepository?.retryForSeller(
jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_RETRYABLE",
"Pricing job was not found or is not failed.",
409,
);
return job;
},
};
}
}
@@ -1,18 +1,34 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createProviderMappingService({ providerMappingRepository }) {
return {
async create({ sellerId, ...input }) {
const result = await providerMappingRepository.createForSeller(input, sellerId);
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404);
const result = await providerMappingRepository.createForSeller(
input,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PRODUCT_NOT_FOUND",
"Product was not found.",
404,
);
return result.mapping;
},
async list({ productId, sellerId }) {
return providerMappingRepository.listForSeller(productId, sellerId);
},
async disable({ id, sellerId }) {
const result = await providerMappingRepository.disableForSeller(id, sellerId);
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404);
const result = await providerMappingRepository.disableForSeller(
id,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PROVIDER_MAPPING_NOT_FOUND",
"Provider mapping was not found.",
404,
);
return result.mapping;
},
};
+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,
@@ -139,4 +198,4 @@ export function createSaleRepository(db) {
});
},
};
}
}
@@ -1,47 +1,64 @@
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
import {
createMultiItemSaleSchema,
createSaleSchema,
voidSaleSchema,
} from "@pokemon-inventory/validation/sale";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
async function defaultAuthenticateAdmin() {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
}
export async function registerAdminSaleRoutes(app, options) {
const {
authenticateAdmin = defaultAuthenticateAdmin,
authorizeUnsafeRequest,
csrfCookieName = 'pokemon_inventory_csrf',
sessionCookieName = 'pokemon_inventory_session',
csrfCookieName = "pokemon_inventory_csrf",
sessionCookieName = "pokemon_inventory_session",
saleService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
}
request.admin = admin;
});
app.post('/sales', async (request, reply) => {
app.post("/sales", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
request.headers["x-csrf-token"],
);
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
const schema = Array.isArray(request.body?.items)
? createMultiItemSaleSchema
: createSaleSchema;
const result = schema.safeParse(request.body);
if (!result.success) {
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
return reply.code(201).send({ data: toAdminSaleDto(sale) });
});
app.get('/sales', async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
app.get("/sales", async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
toAdminSaleDto,
),
}));
app.post('/sales/:id/void', async (request, reply) => {
app.post("/sales/:id/void", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
request.headers["x-csrf-token"],
);
const result = voidSaleSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
@@ -76,4 +95,4 @@ export async function registerAdminSaleRoutes(app, options) {
});
return reply.code(200).send({ data: toAdminSaleDto(sale) });
});
}
}
@@ -1,35 +1,62 @@
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import {
calculateNetSaleProceeds,
createSale,
validateSaleItem,
} from "../domain/sale.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createSaleService({ saleRepository }) {
return {
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
async create({
sellerId,
inventoryItemId,
costBasisMinor,
items,
...input
}) {
const saleItems = items ?? [
{
inventoryItemId,
soldPriceMinor: input.soldPriceMinor,
costBasisMinor,
},
];
const totalSoldPriceMinor = items
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
: input.soldPriceMinor;
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
const sale = createSale({
sellerId,
...input,
soldPriceMinor: totalSoldPriceMinor,
});
const validatedItems = saleItems.map((item) => ({
inventoryItemId: item.inventoryItemId,
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
costBasisMinor: validateSaleItem(item).costBasisMinor,
}));
const result = items
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
? await saleRepository.createForInventoryItems({
sale,
items: validatedItems,
})
: await saleRepository.createForInventoryItem({
sale,
inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor,
});
sale,
inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor,
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
}
if (result.status === 'NOT_ACTIVE') {
if (result.status === "NOT_ACTIVE") {
throw createApplicationError(
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
'Inventory item is not available for sale.',
"INVENTORY_NOT_AVAILABLE_FOR_SALE",
"Inventory item is not available for sale.",
409,
);
}
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
},
async void({ saleId, sellerId, actorUserId, reason }) {
if (typeof reason !== 'string' || reason.trim().length < 3) {
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
if (typeof reason !== "string" || reason.trim().length < 3) {
throw createApplicationError(
"SALE_VOID_REASON_REQUIRED",
"A void reason is required.",
400,
);
}
const result = await saleRepository.voidForSeller({
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
actorUserId,
reason: reason.trim(),
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"SALE_NOT_FOUND",
"Sale was not found.",
404,
);
}
if (result.status === 'ALREADY_VOIDED') {
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
if (result.status === "ALREADY_VOIDED") {
throw createApplicationError(
"SALE_ALREADY_VOIDED",
"Sale is already voided.",
409,
);
}
return withFinancialSummary(result);
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
}
function withFinancialSummary(result) {
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const saleItems = result.saleItems ?? [result.saleItem];
return {
...result,
netProceedsMinor,
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
profitMinor:
netProceedsMinor -
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
};
}
}
@@ -1,5 +1,5 @@
function requiredString(value, fieldName) {
if (typeof value !== 'string') {
if (typeof value !== "string") {
throw new Error(`${fieldName} must be a string.`);
}
@@ -7,16 +7,16 @@ function requiredString(value, fieldName) {
}
export function normalizeSearchQuery(value) {
return requiredString(value, 'Search query')
return requiredString(value, "Search query")
.trim()
.normalize('NFKC')
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.normalize("NFKC")
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
.replace(/\s+/g, " ")
.toLowerCase();
}
export function createSearchQuery({
query = '',
query = "",
category,
conditionId,
minPriceMinor,
@@ -24,54 +24,68 @@ export function createSearchQuery({
page = 1,
limit = 20,
includeSold = false,
sort = 'recent',
sort = "recent",
}) {
const text = normalizeSearchQuery(query);
if (!Number.isInteger(page) || page < 1) {
throw new Error('page must be >= 1');
throw new Error("page must be >= 1");
}
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('limit must be between 1 and 100');
}
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) {
throw new Error('minPriceMinor must be a non-negative integer');
}
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
throw new Error('maxPriceMinor must be a non-negative integer');
throw new Error("limit must be between 1 and 100");
}
if (
typeof minPriceMinor !== 'undefined' &&
typeof maxPriceMinor !== 'undefined' &&
minPriceMinor > maxPriceMinor
typeof minPriceMinor !== "undefined" &&
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
) {
throw new Error('minPriceMinor must be <= maxPriceMinor');
throw new Error("minPriceMinor must be a non-negative integer");
}
if (!['recent', 'price_low', 'price_high'].includes(sort)) {
throw new Error('sort must be recent, price_low, or price_high');
if (
typeof maxPriceMinor !== "undefined" &&
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
) {
throw new Error("maxPriceMinor must be a non-negative integer");
}
if (
typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== "undefined" &&
minPriceMinor > maxPriceMinor
) {
throw new Error("minPriceMinor must be <= maxPriceMinor");
}
if (!["recent", "price_low", "price_high"].includes(sort)) {
throw new Error("sort must be recent, price_low, or price_high");
}
return {
text,
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}),
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}),
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}),
...(typeof category === "string" && category.trim().length > 0
? { category: category.trim() }
: {}),
...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() }
: {}),
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page,
limit,
includeSold,
...(sort !== 'recent' ? { sort } : {}),
...(sort !== "recent" ? { sort } : {}),
};
}
export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) {
export function buildSearchFilters({
includeSold = false,
status = "AVAILABLE",
sort = ["recently_added"],
}) {
return {
status: includeSold ? 'ALL' : status,
status: includeSold ? "ALL" : status,
includeSold,
sort,
};
@@ -1,13 +1,17 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function coerceNumber(value, fieldName, fallback) {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number.parseInt(String(value), 10);
if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400);
throw createApplicationError(
"INVALID_SEARCH_FILTER",
`${fieldName} must be an integer.`,
400,
);
}
return parsed;
}
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
export async function registerPublicSearchRoutes(app, options) {
const { searchService } = options;
app.get('/public/search', async (request) => {
if (!searchService || typeof searchService.searchPublic !== 'function') {
app.get("/public/search", async (request) => {
if (!searchService || typeof searchService.searchPublic !== "function") {
throw createApplicationError(
'SEARCH_SERVICE_UNAVAILABLE',
'Public search is currently unavailable.',
"SEARCH_SERVICE_UNAVAILABLE",
"Public search is currently unavailable.",
503,
);
}
@@ -28,14 +32,24 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query,
category: request.query.category,
conditionId: request.query.conditionId,
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined),
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined),
page: coerceNumber(request.query.page, 'page', 1),
limit: coerceNumber(request.query.limit, 'limit', 20),
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true,
sort: request.query.sort ?? 'recent',
minPriceMinor: coerceNumber(
request.query.minPriceMinor,
"minPriceMinor",
undefined,
),
maxPriceMinor: coerceNumber(
request.query.maxPriceMinor,
"maxPriceMinor",
undefined,
),
page: coerceNumber(request.query.page, "page", 1),
limit: coerceNumber(request.query.limit, "limit", 20),
includeSold:
request.query.includeSold === "true" ||
request.query.includeSold === true,
sort: request.query.sort ?? "recent",
};
return { data: await searchService.searchPublic(filters) };
});
}
}