diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index e04c365..8a0adee 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -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; -} \ No newline at end of file +} diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index b60bb98..ce6aacf 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -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; -}); \ No newline at end of file +}); diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index 7c67e44..ee6d085 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -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), ], -); \ No newline at end of file +); diff --git a/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js b/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js index f5cccb1..b82c1e7 100644 --- a/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js +++ b/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js @@ -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)); }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js b/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js index 9bdca8d..00a5b42 100644 --- a/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js +++ b/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js @@ -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) }); }); -} \ No newline at end of file +} diff --git a/apps/api/src/modules/acquisitions/services/acquisition-service.js b/apps/api/src/modules/acquisitions/services/acquisition-service.js index a1628cb..b72ad24 100644 --- a/apps/api/src/modules/acquisitions/services/acquisition-service.js +++ b/apps/api/src/modules/acquisitions/services/acquisition-service.js @@ -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 { diff --git a/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js b/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js index 7d2fdfd..4ffecd1 100644 --- a/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js +++ b/apps/api/src/modules/dashboard/routes/admin-dashboard-routes.js @@ -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 }), + ), })); -} \ No newline at end of file +} diff --git a/apps/api/src/modules/dashboard/services/dashboard-service.js b/apps/api/src/modules/dashboard/services/dashboard-service.js index 8c657b1..2bcf9db 100644 --- a/apps/api/src/modules/dashboard/services/dashboard-service.js +++ b/apps/api/src/modules/dashboard/services/dashboard-service.js @@ -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), }; }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/inventory/routes/public-lookup-routes.js b/apps/api/src/modules/inventory/routes/public-lookup-routes.js index da09995..3282221 100644 --- a/apps/api/src/modules/inventory/routes/public-lookup-routes.js +++ b/apps/api/src/modules/inventory/routes/public-lookup-routes.js @@ -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 }), + ), })); -} \ No newline at end of file +} diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 3ab93f6..a5e57a5 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -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, diff --git a/apps/api/src/modules/pricing/domain/pricing-observation.js b/apps/api/src/modules/pricing/domain/pricing-observation.js index 8efcf1d..32da4c5 100644 --- a/apps/api/src/modules/pricing/domain/pricing-observation.js +++ b/apps/api/src/modules/pricing/domain/pricing-observation.js @@ -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), + ); } diff --git a/apps/api/src/modules/pricing/providers/fixture-pricing-provider.js b/apps/api/src/modules/pricing/providers/fixture-pricing-provider.js index 0298ce5..8c8962b 100644 --- a/apps/api/src/modules/pricing/providers/fixture-pricing-provider.js +++ b/apps/api/src/modules/pricing/providers/fixture-pricing-provider.js @@ -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, + }, + ]; }, }); } diff --git a/apps/api/src/modules/pricing/providers/pricing-provider.js b/apps/api/src/modules/pricing/providers/pricing-provider.js index 3931c6f..1257ac0 100644 --- a/apps/api/src/modules/pricing/providers/pricing-provider.js +++ b/apps/api/src/modules/pricing/providers/pricing-provider.js @@ -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) { diff --git a/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js b/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js index 7fac333..7881fd9 100644 --- a/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js +++ b/apps/api/src/modules/pricing/repositories/pricing-refresh-job-repository.js @@ -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; }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/pricing/repositories/pricing-repository.js b/apps/api/src/modules/pricing/repositories/pricing-repository.js index c55f6c9..f2612f9 100644 --- a/apps/api/src/modules/pricing/repositories/pricing-repository.js +++ b/apps/api/src/modules/pricing/repositories/pricing-repository.js @@ -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 }; }); }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js index 692ac74..b4b0f8d 100644 --- a/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js +++ b/apps/api/src/modules/pricing/repositories/provider-mapping-repository.js @@ -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 }; }, }; } diff --git a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js index 36effa2..904e1f7 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -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 }, + }); }); -} \ No newline at end of file +} diff --git a/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js b/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js index 97b52c5..4522dfe 100644 --- a/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-provider-mapping-routes.js @@ -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) }); }); } diff --git a/apps/api/src/modules/pricing/services/pricing-service.js b/apps/api/src/modules/pricing/services/pricing-service.js index 5c4c1cb..9960430 100644 --- a/apps/api/src/modules/pricing/services/pricing-service.js +++ b/apps/api/src/modules/pricing/services/pricing-service.js @@ -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; }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/pricing/services/provider-mapping-service.js b/apps/api/src/modules/pricing/services/provider-mapping-service.js index 7fa115e..10ace2a 100644 --- a/apps/api/src/modules/pricing/services/provider-mapping-service.js +++ b/apps/api/src/modules/pricing/services/provider-mapping-service.js @@ -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; }, }; diff --git a/apps/api/src/modules/sales/domain/sale.js b/apps/api/src/modules/sales/domain/sale.js index 1fc50d6..665062a 100644 --- a/apps/api/src/modules/sales/domain/sale.js +++ b/apps/api/src/modules/sales/domain/sale.js @@ -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 + ); } diff --git a/apps/api/src/modules/sales/repositories/sale-repository.js b/apps/api/src/modules/sales/repositories/sale-repository.js index f307665..1328b71 100644 --- a/apps/api/src/modules/sales/repositories/sale-repository.js +++ b/apps/api/src/modules/sales/repositories/sale-repository.js @@ -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) { }); }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/sales/routes/admin-sale-routes.js b/apps/api/src/modules/sales/routes/admin-sale-routes.js index 75450d0..1ef2942 100644 --- a/apps/api/src/modules/sales/routes/admin-sale-routes.js +++ b/apps/api/src/modules/sales/routes/admin-sale-routes.js @@ -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) }); }); -} \ No newline at end of file +} diff --git a/apps/api/src/modules/sales/services/sale-service.js b/apps/api/src/modules/sales/services/sale-service.js index f6029c8..83a365a 100644 --- a/apps/api/src/modules/sales/services/sale-service.js +++ b/apps/api/src/modules/sales/services/sale-service.js @@ -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), }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/search/domain/search-query.js b/apps/api/src/modules/search/domain/search-query.js index f6cd043..b224776 100644 --- a/apps/api/src/modules/search/domain/search-query.js +++ b/apps/api/src/modules/search/domain/search-query.js @@ -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, }; diff --git a/apps/api/src/modules/search/routes/public-search-routes.js b/apps/api/src/modules/search/routes/public-search-routes.js index 7155aba..60b977d 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -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) }; }); -} \ No newline at end of file +} diff --git a/apps/api/tests/application/acquisition-service.test.js b/apps/api/tests/application/acquisition-service.test.js index 18db129..73760d4 100644 --- a/apps/api/tests/application/acquisition-service.test.js +++ b/apps/api/tests/application/acquisition-service.test.js @@ -1,23 +1,26 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js'; +import { createAcquisitionService } from "../../src/modules/acquisitions/services/acquisition-service.js"; -test('creates an owned acquisition and calculates cost basis', async () => { +test("creates an owned acquisition and calculates cost basis", async () => { let received; const service = createAcquisitionService({ acquisitionRepository: { async createForSeller(input) { received = input; - return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } }; + return { + status: "CREATED", + acquisition: { id: "acquisition-1", ...input.acquisition }, + }; }, }, }); const result = await service.create({ - sellerId: 'seller-1', - inventoryItemId: 'inventory-1', - method: 'PURCHASE', + sellerId: "seller-1", + inventoryItemId: "inventory-1", + method: "PURCHASE", costMinor: 1000, shippingMinor: 150, taxMinor: 75, @@ -25,17 +28,27 @@ test('creates an owned acquisition and calculates cost basis', async () => { estimatedTradeValueMinor: 200, }); - assert.equal(received.sellerId, 'seller-1'); + assert.equal(received.sellerId, "seller-1"); assert.equal(result.costBasisMinor, 1075); }); -test('rejects acquisition for an inventory item outside the seller account', async () => { +test("rejects acquisition for an inventory item outside the seller account", async () => { const service = createAcquisitionService({ - acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } }, + acquisitionRepository: { + async createForSeller() { + return { status: "NOT_FOUND" }; + }, + }, }); await assert.rejects( - () => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }), - (error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404, + () => + service.create({ + sellerId: "seller-1", + inventoryItemId: "inventory-1", + method: "PURCHASE", + costMinor: 100, + }), + (error) => error.code === "INVENTORY_NOT_FOUND" && error.statusCode === 404, ); -}); \ No newline at end of file +}); diff --git a/apps/api/tests/application/dashboard-service.test.js b/apps/api/tests/application/dashboard-service.test.js index 6c239aa..ae9715e 100644 --- a/apps/api/tests/application/dashboard-service.test.js +++ b/apps/api/tests/application/dashboard-service.test.js @@ -1,20 +1,59 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js'; +import { createDashboardService } from "../../src/modules/dashboard/services/dashboard-service.js"; -test('aggregates seller dashboard counts and financial totals', async () => { +test("aggregates seller dashboard counts and financial totals", async () => { const service = createDashboardService({ - productService: { async listBySellerId() { return [{ id: 'product-1' }]; } }, - inventoryItemService: { async listBySellerId() { return [{ id: 'item-1', status: 'ACTIVE' }, { id: 'item-2', status: 'SOLD' }]; } }, - listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } }, - saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } }, - acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } }, - pricingRepository: { async listLatestValuationsForSeller() { return [{ inventoryItemId: 'item-1', valueMinor: 2800, calculatedAt: new Date() }]; } }, - pricingRefreshJobRepository: { async countFailedForSeller() { return 2; } }, + productService: { + async listBySellerId() { + return [{ id: "product-1" }]; + }, + }, + inventoryItemService: { + async listBySellerId() { + return [ + { id: "item-1", status: "ACTIVE" }, + { id: "item-2", status: "SOLD" }, + ]; + }, + }, + listingService: { + async list() { + return [{ status: "AVAILABLE", askingPriceMinor: 2500, imageCount: 1 }]; + }, + }, + saleService: { + async list() { + return [ + { status: "COMPLETED", netProceedsMinor: 2200, profitMinor: 700 }, + ]; + }, + }, + acquisitionService: { + async list() { + return [{ costBasisMinor: 1500 }]; + }, + }, + pricingRepository: { + async listLatestValuationsForSeller() { + return [ + { + inventoryItemId: "item-1", + valueMinor: 2800, + calculatedAt: new Date(), + }, + ]; + }, + }, + pricingRefreshJobRepository: { + async countFailedForSeller() { + return 2; + }, + }, }); - const dashboard = await service.get({ sellerId: 'seller-1' }); + const dashboard = await service.get({ sellerId: "seller-1" }); assert.deepEqual(dashboard.counts, { products: 1, inventory: 2, @@ -35,4 +74,4 @@ test('aggregates seller dashboard counts and financial totals', async () => { totalProfitMinor: 700, totalMarketValueMinor: 2800, }); -}); \ No newline at end of file +}); diff --git a/apps/api/tests/application/pricing-provider.test.js b/apps/api/tests/application/pricing-provider.test.js index 4cccc14..f2972ce 100644 --- a/apps/api/tests/application/pricing-provider.test.js +++ b/apps/api/tests/application/pricing-provider.test.js @@ -1,38 +1,87 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js'; -import { createFixturePricingProvider } from '../../src/modules/pricing/providers/fixture-pricing-provider.js'; +import { + createPricingProvider, + createPricingProviderRegistry, +} from "../../src/modules/pricing/providers/pricing-provider.js"; +import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js"; +import { createHttpPricingProvider } from "../../src/modules/pricing/providers/http-pricing-provider.js"; -test('registers pricing providers behind a validated adapter contract', async () => { +test("registers pricing providers behind a validated adapter contract", async () => { const provider = createPricingProvider({ - id: 'example', - async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; }, + id: "example", + async observe() { + return [{ priceMinor: 2500, sourceIdentifier: "example-1" }]; + }, }); const registry = createPricingProviderRegistry([provider]); - assert.equal(registry.get('example'), provider); - assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]); + assert.equal(registry.get("example"), provider); + assert.deepEqual(await registry.get("example").observe({}), [ + { priceMinor: 2500, sourceIdentifier: "example-1" }, + ]); assert.deepEqual(registry.list(), [provider]); }); -test('rejects malformed provider adapters and responses', async () => { - assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/); - const provider = createPricingProvider({ id: 'broken', async observe() { return null; } }); - await assert.rejects(() => provider.observe({}), /must return an observation array/); +test("rejects malformed provider adapters and responses", async () => { + assert.throws( + () => createPricingProvider({ id: "broken" }), + /observe function is required/, + ); + const provider = createPricingProvider({ + id: "broken", + async observe() { + return null; + }, + }); + await assert.rejects( + () => provider.observe({}), + /must return an observation array/, + ); }); -test('fixture provider returns deterministic normalized observations', async () => { +test("fixture provider returns deterministic normalized observations", async () => { const [observation] = await createFixturePricingProvider().observe({ - productId: 'product-1', inventoryItemId: 'item-1', conditionId: 'condition-1', + productId: "product-1", + inventoryItemId: "item-1", + conditionId: "condition-1", }); assert.deepEqual(observation, { - productId: 'product-1', - inventoryItemId: 'item-1', - conditionId: 'condition-1', + productId: "product-1", + inventoryItemId: "item-1", + conditionId: "condition-1", priceMinor: 2500, - currency: 'USD', - sourceIdentifier: 'fixture-item-1', + currency: "USD", + sourceIdentifier: "fixture-item-1", qualityScore: 50, }); -}); \ No newline at end of file +}); + +test("http provider sends mapping context and normalizes its response", async () => { + let request; + const provider = createHttpPricingProvider({ + id: "http-provider", + endpoint: "https://provider.example/prices", + token: "test-token", + async fetchImplementation(url, options) { + request = { url: String(url), options }; + return { + ok: true, + async json() { + return { observations: [{ priceMinor: 2500 }] }; + }, + }; + }, + }); + + assert.deepEqual( + await provider.observe({ + externalIdentifier: "catalog-1", + searchQuery: "Charizard", + }), + [{ priceMinor: 2500 }], + ); + assert.match(request.url, /externalIdentifier=catalog-1/); + assert.equal(request.options.headers.authorization, "Bearer test-token"); +}); diff --git a/apps/api/tests/application/pricing-service.test.js b/apps/api/tests/application/pricing-service.test.js index 76702fe..9ef3e2a 100644 --- a/apps/api/tests/application/pricing-service.test.js +++ b/apps/api/tests/application/pricing-service.test.js @@ -1,77 +1,137 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js'; +import { createPricingService } from "../../src/modules/pricing/services/pricing-service.js"; -test('records an owned pricing observation and calculates a valuation', async () => { +test("records an owned pricing observation and calculates a valuation", async () => { const service = createPricingService({ pricingRepository: { async addObservationForSeller({ observation, sellerId }) { - assert.equal(sellerId, 'seller-1'); - return { status: 'CREATED', observation }; + assert.equal(sellerId, "seller-1"); + return { status: "CREATED", observation }; }, async markOutliersForSeller() { - return { status: 'UPDATED' }; + return { status: "UPDATED" }; }, async calculateForSeller() { - return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] }; + return { + status: "CALCULATED", + valuation: { valueMinor: 2500 }, + observations: [], + }; }, }, }); const observation = await service.addObservation({ - sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1', - providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1', + sellerId: "seller-1", + productId: "product-1", + inventoryItemId: "inventory-1", + providerId: "manual", + priceMinor: 2500, + sourceIdentifier: "source-1", + conditionId: "condition-1", }); assert.equal(observation.priceMinor, 2500); - const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' }); + const valuation = await service.calculate({ + sellerId: "seller-1", + inventoryItemId: "inventory-1", + }); assert.equal(valuation.valuation.valueMinor, 2500); }); -test('requires a reason for an audited market-value override', async () => { - const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } }); +test("requires a reason for an audited market-value override", async () => { + const service = createPricingService({ + pricingRepository: { + async createOverride() { + throw new Error("should not run"); + }, + }, + }); await assert.rejects( - () => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }), - (error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400, + () => + service.override({ + sellerId: "seller-1", + actorUserId: "user-1", + inventoryItemId: "inventory-1", + valueMinor: 2000, + reason: "x", + }), + (error) => + error.code === "OVERRIDE_REASON_REQUIRED" && error.statusCode === 400, ); }); -test('fails clearly when a requested pricing provider is unavailable', async () => { +test("fails clearly when a requested pricing provider is unavailable", async () => { const service = createPricingService({ pricingRepository: {}, - pricingProviderRegistry: { get() { return null; } }, + pricingProviderRegistry: { + get() { + return null; + }, + }, }); await assert.rejects( - () => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }), - (error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, + () => + service.refresh({ + sellerId: "seller-1", + inventoryItemId: "inventory-1", + providerId: "missing", + }), + (error) => + error.code === "PRICING_PROVIDER_UNAVAILABLE" && error.statusCode === 503, ); }); -test('enqueues a seller-scoped pricing refresh and reads its status', async () => { +test("enqueues a seller-scoped pricing refresh and reads its status", async () => { const service = createPricingService({ pricingRepository: {}, pricingRefreshJobRepository: { - async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; }, - async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; }, + async create(input) { + return { id: "job-1", status: "PENDING", ...input }; + }, + async findByIdForSeller(id, sellerId) { + return { id, sellerId, status: "COMPLETED" }; + }, + }, + providerMappingRepository: { + async findActiveForSeller() { + return { providerId: "mapped-provider" }; + }, }, - providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } }, }); - const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' }); - assert.equal(job.status, 'PENDING'); - assert.equal(job.providerId, 'mapped-provider'); - assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED'); + const job = await service.enqueueRefresh({ + sellerId: "seller-1", + inventoryItemId: "item-1", + providerId: "provider-1", + productId: "product-1", + conditionId: "condition-1", + }); + assert.equal(job.status, "PENDING"); + assert.equal(job.providerId, "mapped-provider"); + assert.equal( + (await service.getJob({ sellerId: "seller-1", jobId: job.id })).status, + "COMPLETED", + ); }); -test('lists and retries seller-scoped failed pricing jobs', async () => { +test("lists and retries seller-scoped failed pricing jobs", async () => { const service = createPricingService({ pricingRepository: {}, pricingRefreshJobRepository: { - async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; }, - async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; }, + async listForSeller() { + return [{ id: "job-1", status: "FAILED" }]; + }, + async retryForSeller() { + return { id: "job-1", status: "PENDING" }; + }, }, }); - assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1); - assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING'); -}); \ No newline at end of file + assert.equal((await service.listJobs({ sellerId: "seller-1" })).length, 1); + assert.equal( + (await service.retryJob({ sellerId: "seller-1", jobId: "job-1" })).status, + "PENDING", + ); +}); diff --git a/apps/api/tests/application/provider-mapping-service.test.js b/apps/api/tests/application/provider-mapping-service.test.js index 8d905bc..02a21de 100644 --- a/apps/api/tests/application/provider-mapping-service.test.js +++ b/apps/api/tests/application/provider-mapping-service.test.js @@ -1,21 +1,44 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js'; +import { createProviderMappingService } from "../../src/modules/pricing/services/provider-mapping-service.js"; -test('creates and disables seller-owned provider mappings', async () => { +test("creates and disables seller-owned provider mappings", async () => { const calls = []; const service = createProviderMappingService({ providerMappingRepository: { - async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; }, - async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; }, - async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; }, + async createForSeller(input, sellerId) { + calls.push(["create", input, sellerId]); + return { status: "CREATED", mapping: { id: "mapping-1", ...input } }; + }, + async listForSeller() { + return [{ id: "mapping-1", status: "ACTIVE" }]; + }, + async disableForSeller() { + return { + status: "DISABLED", + mapping: { id: "mapping-1", status: "DISABLED" }, + }; + }, }, }); - const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }); - assert.equal(created.id, 'mapping-1'); - assert.equal(calls[0][2], 'seller-1'); - assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1); - assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED'); + const created = await service.create({ + sellerId: "seller-1", + productId: "product-1", + providerId: "fixture", + mappingType: "PRODUCT", + externalIdentifier: "ext-1", + }); + assert.equal(created.id, "mapping-1"); + assert.equal(calls[0][2], "seller-1"); + assert.equal( + (await service.list({ sellerId: "seller-1", productId: "product-1" })) + .length, + 1, + ); + assert.equal( + (await service.disable({ sellerId: "seller-1", id: "mapping-1" })).status, + "DISABLED", + ); }); diff --git a/apps/api/tests/application/sale-service.test.js b/apps/api/tests/application/sale-service.test.js index 7388b69..958d499 100644 --- a/apps/api/tests/application/sale-service.test.js +++ b/apps/api/tests/application/sale-service.test.js @@ -1,29 +1,29 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { createSaleService } from '../../src/modules/sales/services/sale-service.js'; +import { createSaleService } from "../../src/modules/sales/services/sale-service.js"; function validInput(overrides = {}) { return { - sellerId: 'seller-1', - inventoryItemId: 'inventory-1', - saleDate: '2026-09-09', + sellerId: "seller-1", + inventoryItemId: "inventory-1", + saleDate: "2026-09-09", soldPriceMinor: 2499, costBasisMinor: 1200, ...overrides, }; } -test('creates a sale through the repository with validated money values', async () => { +test("creates a sale through the repository with validated money values", async () => { let received; const service = createSaleService({ saleRepository: { async createForInventoryItem(input) { received = input; return { - status: 'CREATED', + status: "CREATED", sale: { - id: 'sale-1', + id: "sale-1", soldPriceMinor: 2499, platformFeesMinor: 100, paymentProcessingFeesMinor: 0, @@ -38,9 +38,9 @@ test('creates a sale through the repository with validated money values', async }); assert.deepEqual(await service.create(validInput()), { - status: 'CREATED', + status: "CREATED", sale: { - id: 'sale-1', + id: "sale-1", soldPriceMinor: 2499, platformFeesMinor: 100, paymentProcessingFeesMinor: 0, @@ -52,85 +52,105 @@ test('creates a sale through the repository with validated money values', async netProceedsMinor: 2399, profitMinor: 1199, }); - assert.equal(received.sale.sellerId, 'seller-1'); + assert.equal(received.sale.sellerId, "seller-1"); assert.equal(received.sale.soldPriceMinor, 2499); assert.equal(received.costBasisMinor, 1200); }); -test('lists seller sales with derived net proceeds and profit', async () => { +test("lists seller sales with derived net proceeds and profit", async () => { const service = createSaleService({ saleRepository: { async listBySellerId(sellerId) { - assert.equal(sellerId, 'seller-1'); - return [{ - sale: { soldPriceMinor: 3000, shippingCostMinor: 200 }, - saleItem: { costBasisMinor: 1500 }, - inventoryItem: { id: 'inventory-1', status: 'SOLD' }, - }]; + assert.equal(sellerId, "seller-1"); + return [ + { + sale: { soldPriceMinor: 3000, shippingCostMinor: 200 }, + saleItem: { costBasisMinor: 1500 }, + inventoryItem: { id: "inventory-1", status: "SOLD" }, + }, + ]; }, }, }); - const [sale] = await service.list({ sellerId: 'seller-1' }); + const [sale] = await service.list({ sellerId: "seller-1" }); assert.equal(sale.netProceedsMinor, 2800); assert.equal(sale.profitMinor, 1300); }); -test('voids an owned sale with a required reason', async () => { +test("voids an owned sale with a required reason", async () => { let received; const service = createSaleService({ saleRepository: { async voidForSeller(input) { received = input; return { - status: 'VOIDED', - sale: { id: 'sale-1', soldPriceMinor: 3000 }, + status: "VOIDED", + sale: { id: "sale-1", soldPriceMinor: 3000 }, saleItem: { costBasisMinor: 1500 }, - inventoryItem: { id: 'inventory-1', status: 'ACTIVE' }, + inventoryItem: { id: "inventory-1", status: "ACTIVE" }, }; }, }, }); const result = await service.void({ - saleId: 'sale-1', - sellerId: 'seller-1', - actorUserId: 'user-1', - reason: ' Duplicate sale ', + saleId: "sale-1", + sellerId: "seller-1", + actorUserId: "user-1", + reason: " Duplicate sale ", }); - assert.equal(received.reason, 'Duplicate sale'); - assert.equal(result.inventoryItem.status, 'ACTIVE'); + assert.equal(received.reason, "Duplicate sale"); + assert.equal(result.inventoryItem.status, "ACTIVE"); await assert.rejects( - () => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }), - (error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400, + () => + service.void({ + saleId: "sale-1", + sellerId: "seller-1", + actorUserId: "user-1", + reason: "x", + }), + (error) => + error.code === "SALE_VOID_REASON_REQUIRED" && error.statusCode === 400, ); }); -test('creates one sale containing multiple inventory items', async () => { +test("creates one sale containing multiple inventory items", async () => { let received; const service = createSaleService({ saleRepository: { async createForInventoryItems(input) { received = input; return { - status: 'CREATED', - sale: { id: 'sale-2', soldPriceMinor: 3000 }, + status: "CREATED", + sale: { id: "sale-2", soldPriceMinor: 3000 }, saleItems: input.items, saleItem: input.items[0], - inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })), - inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' }, + inventoryItems: input.items.map(({ inventoryItemId }) => ({ + id: inventoryItemId, + status: "SOLD", + })), + inventoryItem: { id: input.items[0].inventoryItemId, status: "SOLD" }, }; }, }, }); const result = await service.create({ - sellerId: 'seller-1', - saleDate: '2026-09-09', + sellerId: "seller-1", + saleDate: "2026-09-09", items: [ - { inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 }, - { inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 }, + { + inventoryItemId: "inventory-1", + soldPriceMinor: 1200, + costBasisMinor: 500, + }, + { + inventoryItemId: "inventory-2", + soldPriceMinor: 1800, + costBasisMinor: 700, + }, ], }); @@ -139,18 +159,27 @@ test('creates one sale containing multiple inventory items', async () => { assert.equal(result.profitMinor, 1800); }); -test('maps missing and already-sold inventory to application errors', async () => { +test("maps missing and already-sold inventory to application errors", async () => { for (const result of [ - { status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 }, - { status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 }, + { status: "NOT_FOUND", code: "INVENTORY_NOT_FOUND", statusCode: 404 }, + { + status: "NOT_ACTIVE", + code: "INVENTORY_NOT_AVAILABLE_FOR_SALE", + statusCode: 409, + }, ]) { const service = createSaleService({ - saleRepository: { async createForInventoryItem() { return result; } }, + saleRepository: { + async createForInventoryItem() { + return result; + }, + }, }); await assert.rejects( () => service.create(validInput()), - (error) => error.code === result.code && error.statusCode === result.statusCode, + (error) => + error.code === result.code && error.statusCode === result.statusCode, ); } -}); \ No newline at end of file +}); diff --git a/apps/api/tests/domain/acquisition-pricing.test.js b/apps/api/tests/domain/acquisition-pricing.test.js index b9e3270..128d76a 100644 --- a/apps/api/tests/domain/acquisition-pricing.test.js +++ b/apps/api/tests/domain/acquisition-pricing.test.js @@ -1,17 +1,17 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; import { calculateAcquisitionCostBasis, createAcquisition, -} from '../../src/modules/acquisitions/domain/acquisition.js'; +} from "../../src/modules/acquisitions/domain/acquisition.js"; import { calculateMedianPriceMinor, createPricingObservation, identifyOutlierPrices, -} from '../../src/modules/pricing/domain/pricing-observation.js'; +} from "../../src/modules/pricing/domain/pricing-observation.js"; -test('calculates acquisition cost basis from all cost components', () => { +test("calculates acquisition cost basis from all cost components", () => { const total = calculateAcquisitionCostBasis({ costMinor: 1000, shippingMinor: 150, @@ -23,43 +23,48 @@ test('calculates acquisition cost basis from all cost components', () => { assert.equal(total, 1075); }); -test('rejects negative acquisition values and missing identity fields', () => { +test("rejects negative acquisition values and missing identity fields", () => { assert.throws( - () => createAcquisition({ - inventoryItemId: 'inventory-item-id', - method: 'PURCHASE', - costMinor: -1, - }), + () => + createAcquisition({ + inventoryItemId: "inventory-item-id", + method: "PURCHASE", + costMinor: -1, + }), /must be a non-negative integer minor unit/, ); assert.throws( - () => createAcquisition({ - inventoryItemId: '', - method: 'PURCHASE', - costMinor: 1000, - }), + () => + createAcquisition({ + inventoryItemId: "", + method: "PURCHASE", + costMinor: 1000, + }), /Inventory item ID is required/, ); }); -test('creates pricing observations and calculates a median observed price', () => { +test("creates pricing observations and calculates a median observed price", () => { const observation = createPricingObservation({ - productId: 'product-id', - inventoryItemId: 'inventory-item-id', - providerId: 'ebay', + productId: "product-id", + inventoryItemId: "inventory-item-id", + providerId: "ebay", priceMinor: 2499, - currency: 'USD', - sourceIdentifier: 'listing-1', - conditionId: 'condition-id', + currency: "USD", + sourceIdentifier: "listing-1", + conditionId: "condition-id", }); - assert.equal(observation.currency, 'USD'); + assert.equal(observation.currency, "USD"); assert.equal(observation.priceMinor, 2499); assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000); }); -test('identifies extreme pricing observations without discarding normal values', () => { - assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]); +test("identifies extreme pricing observations without discarding normal values", () => { + assert.deepEqual( + [...identifyOutlierPrices([1000, 1050, 1100, 5000])], + [5000], + ); assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0); }); diff --git a/apps/api/tests/domain/sale.test.js b/apps/api/tests/domain/sale.test.js index 35e4ed5..8f3f5c6 100644 --- a/apps/api/tests/domain/sale.test.js +++ b/apps/api/tests/domain/sale.test.js @@ -1,76 +1,83 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; import { calculateNetSaleProceeds, createSale, createSaleItem, -} from '../../src/modules/sales/domain/sale.js'; +} from "../../src/modules/sales/domain/sale.js"; -test('creates a valid sale record and calculates net proceeds after fees', () => { +test("creates a valid sale record and calculates net proceeds after fees", () => { const sale = createSale({ - sellerId: 'seller-id', - saleDate: '2026-09-08', - saleLocation: 'eBay', + sellerId: "seller-id", + saleDate: "2026-09-08", + saleLocation: "eBay", soldPriceMinor: 4999, - currency: 'USD', + currency: "USD", platformFeesMinor: 300, paymentProcessingFeesMinor: 120, shippingCostMinor: 50, taxesMinor: 90, otherFeesMinor: 40, - buyerName: 'Alex', - buyerContact: 'alex@example.com', + buyerName: "Alex", + buyerContact: "alex@example.com", }); assert.equal(sale.soldPriceMinor, 4999); - assert.equal(sale.currency, 'USD'); - assert.equal(calculateNetSaleProceeds({ - soldPriceMinor: 4999, - platformFeesMinor: 300, - paymentProcessingFeesMinor: 120, - shippingCostMinor: 50, - taxesMinor: 90, - otherFeesMinor: 40, - }), 4399); + assert.equal(sale.currency, "USD"); + assert.equal( + calculateNetSaleProceeds({ + soldPriceMinor: 4999, + platformFeesMinor: 300, + paymentProcessingFeesMinor: 120, + shippingCostMinor: 50, + taxesMinor: 90, + otherFeesMinor: 40, + }), + 4399, + ); }); -test('rejects invalid sales and negative fee values', () => { +test("rejects invalid sales and negative fee values", () => { assert.throws( - () => createSale({ - sellerId: 'seller-id', - saleDate: '2026-09-08', - soldPriceMinor: 0, - }), + () => + createSale({ + sellerId: "seller-id", + saleDate: "2026-09-08", + soldPriceMinor: 0, + }), /must be a positive integer minor unit/, ); assert.throws( - () => createSaleItem({ - saleId: 'sale-id', - inventoryItemId: 'inventory-item-id', - soldPriceMinor: 2000, - costBasisMinor: -1, - }), + () => + createSaleItem({ + saleId: "sale-id", + inventoryItemId: "inventory-item-id", + soldPriceMinor: 2000, + costBasisMinor: -1, + }), /must be a non-negative integer minor unit/, ); assert.throws( - () => createSale({ - sellerId: 'seller-id', - saleDate: '2026-02-30', - soldPriceMinor: 2000, - }), + () => + createSale({ + sellerId: "seller-id", + saleDate: "2026-02-30", + soldPriceMinor: 2000, + }), /must be a valid ISO date/, ); assert.throws( - () => createSale({ - sellerId: 'seller-id', - saleDate: '2026-09-08', - soldPriceMinor: 2000, - currency: 'US dollars', - }), + () => + createSale({ + sellerId: "seller-id", + saleDate: "2026-09-08", + soldPriceMinor: 2000, + currency: "US dollars", + }), /must be a three-letter ISO currency code/, ); }); diff --git a/apps/api/tests/domain/search-query.test.js b/apps/api/tests/domain/search-query.test.js index 3800567..54cef33 100644 --- a/apps/api/tests/domain/search-query.test.js +++ b/apps/api/tests/domain/search-query.test.js @@ -1,16 +1,16 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; import { buildSearchFilters, createSearchQuery, normalizeSearchQuery, -} from '../../src/modules/search/domain/search-query.js'; +} from "../../src/modules/search/domain/search-query.js"; -test('normalizes a public search query and keeps filter values within bounds', () => { +test("normalizes a public search query and keeps filter values within bounds", () => { const query = createSearchQuery({ - query: ' Charizard ex ', - category: 'Cards', + query: " Charizard ex ", + category: "Cards", minPriceMinor: 1000, maxPriceMinor: 5000, page: 1, @@ -18,8 +18,8 @@ test('normalizes a public search query and keeps filter values within bounds', ( includeSold: false, }); - assert.equal(query.text, 'charizard ex'); - assert.equal(query.category, 'Cards'); + assert.equal(query.text, "charizard ex"); + assert.equal(query.category, "Cards"); assert.equal(query.minPriceMinor, 1000); assert.equal(query.maxPriceMinor, 5000); assert.equal(query.page, 1); @@ -27,25 +27,45 @@ test('normalizes a public search query and keeps filter values within bounds', ( assert.equal(query.includeSold, false); }); -test('rejects invalid pagination and price boundaries', () => { - assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/); - assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/); - assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/); +test("rejects invalid pagination and price boundaries", () => { + assert.throws( + () => createSearchQuery({ query: "abc", page: 0, limit: 20 }), + /page must be >= 1/, + ); + assert.throws( + () => + createSearchQuery({ + query: "abc", + minPriceMinor: 5000, + maxPriceMinor: 1000, + }), + /minPriceMinor must be <= maxPriceMinor/, + ); + assert.throws( + () => createSearchQuery({ query: "abc", limit: 0 }), + /limit must be between 1 and 100/, + ); }); -test('builds a public query filter set that hides sold items by default', () => { +test("builds a public query filter set that hides sold items by default", () => { const filters = buildSearchFilters({ includeSold: false }); - assert.equal(filters.status, 'AVAILABLE'); + assert.equal(filters.status, "AVAILABLE"); assert.equal(filters.includeSold, false); - assert.deepEqual(filters.sort, ['recently_added']); + assert.deepEqual(filters.sort, ["recently_added"]); }); -test('accepts only the supported public search sort values', () => { - assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low'); - assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/); +test("accepts only the supported public search sort values", () => { + assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low"); + assert.throws( + () => createSearchQuery({ sort: "random" }), + /sort must be recent/, + ); }); -test('normalizes whitespace and punctuation in free-text search terms', () => { - assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo'); +test("normalizes whitespace and punctuation in free-text search terms", () => { + assert.equal( + normalizeSearchQuery(" Pikachu #199 / holo "), + "pikachu 199 holo", + ); }); diff --git a/apps/api/tests/http/acquisition-routes.test.js b/apps/api/tests/http/acquisition-routes.test.js index 7b9ba2c..2509a5f 100644 --- a/apps/api/tests/http/acquisition-routes.test.js +++ b/apps/api/tests/http/acquisition-routes.test.js @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { return buildApp({ @@ -9,7 +9,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { authorizeUnsafeRequest, acquisitionService: { async create(input) { - return { id: 'acquisition-1', ...input, costBasisMinor: 1075 }; + return { id: "acquisition-1", ...input, costBasisMinor: 1075 }; }, async list() { return []; @@ -18,21 +18,23 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { }); } -test('creates an acquisition for the authenticated seller', async (testContext) => { +test("creates an acquisition for the authenticated seller", async (testContext) => { let csrfChecked = false; const app = createTestApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), - authorizeUnsafeRequest: async () => { csrfChecked = true; }, + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + authorizeUnsafeRequest: async () => { + csrfChecked = true; + }, }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/acquisitions', - headers: { 'x-csrf-token': 'csrf-token' }, + method: "POST", + url: "/api/v1/admin/acquisitions", + headers: { "x-csrf-token": "csrf-token" }, payload: { - inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', - method: 'PURCHASE', + inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8", + method: "PURCHASE", costMinor: 1000, }, }); @@ -42,16 +44,18 @@ test('creates an acquisition for the authenticated seller', async (testContext) assert.equal(csrfChecked, true); }); -test('rejects invalid acquisition requests', async (testContext) => { - const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); +test("rejects invalid acquisition requests", async (testContext) => { + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/acquisitions', - payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 }, + method: "POST", + url: "/api/v1/admin/acquisitions", + payload: { inventoryItemId: "not-a-uuid", method: "", costMinor: -1 }, }); assert.equal(response.statusCode, 400); - assert.equal(response.json().error.code, 'VALIDATION_ERROR'); -}); \ No newline at end of file + assert.equal(response.json().error.code, "VALIDATION_ERROR"); +}); diff --git a/apps/api/tests/http/dashboard-routes.test.js b/apps/api/tests/http/dashboard-routes.test.js index 82853b4..498c7dd 100644 --- a/apps/api/tests/http/dashboard-routes.test.js +++ b/apps/api/tests/http/dashboard-routes.test.js @@ -1,16 +1,34 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; -test('returns a seller-scoped dashboard', async (testContext) => { +test("returns a seller-scoped dashboard", async (testContext) => { const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), - dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } }, + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + dashboardService: { + async get({ sellerId }) { + assert.equal(sellerId, "seller-1"); + return { + counts: {}, + financials: {}, + recentSales: [], + recentAcquisitions: [], + }; + }, + }, }); testContext.after(() => app.close()); - const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + }); assert.equal(response.statusCode, 200); - assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }); -}); \ No newline at end of file + assert.deepEqual(response.json().data, { + counts: {}, + financials: {}, + recentSales: [], + recentAcquisitions: [], + }); +}); diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js index 6c0f8da..35436b4 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -1,25 +1,54 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; -test('accepts a protected pricing observation and valuation request', async (testContext) => { +test("accepts a protected pricing observation and valuation request", async (testContext) => { let csrfChecks = 0; const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), - authorizeUnsafeRequest: async () => { csrfChecks += 1; }, + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + authorizeUnsafeRequest: async () => { + csrfChecks += 1; + }, pricingService: { - async addObservation(input) { return { id: 'observation-1', ...input }; }, - async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; }, + async addObservation(input) { + return { id: "observation-1", ...input }; + }, + async calculate() { + return { + valuation: { + id: "valuation-1", + inventoryItemId: "inventory-1", + valueMinor: 2500, + currency: "USD", + strategy: "MEDIAN", + confidence: "LOW", + sourceCount: 1, + }, + observations: [], + }; + }, }, }); testContext.after(() => app.close()); const observationResponse = await app.inject({ - method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' }, - payload: { productId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958' }, + method: "POST", + url: "/api/v1/admin/pricing/observations", + headers: { "x-csrf-token": "csrf" }, + payload: { + productId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8", + inventoryItemId: "21eb218b-7a95-4f80-a9e3-e306f7ca1f2e", + providerId: "manual", + priceMinor: 2500, + sourceIdentifier: "source-1", + conditionId: "ab8150c7-96c1-4b55-8d69-7d0b77d48958", + }, + }); + const valuationResponse = await app.inject({ + method: "POST", + url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation", }); - const valuationResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation' }); assert.equal(observationResponse.statusCode, 201); assert.equal(valuationResponse.statusCode, 200); @@ -27,59 +56,74 @@ test('accepts a protected pricing observation and valuation request', async (tes assert.equal(csrfChecks, 2); }); -test('accepts a CSRF-protected market-value override', async (testContext) => { +test("accepts a CSRF-protected market-value override", async (testContext) => { let received; const app = buildApp({ - authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }), authorizeUnsafeRequest: async () => {}, - pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } }, + pricingService: { + async override(input) { + received = input; + return { id: "override-1", ...input }; + }, + }, }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override', - headers: { 'x-csrf-token': 'csrf' }, - payload: { valueMinor: 2600, reason: 'Verified recent sale' }, + method: "POST", + url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override", + headers: { "x-csrf-token": "csrf" }, + payload: { valueMinor: 2600, reason: "Verified recent sale" }, }); assert.equal(response.statusCode, 201); - assert.equal(received.actorUserId, 'user-1'); + assert.equal(received.actorUserId, "user-1"); assert.equal(response.json().data.valueMinor, 2600); }); -test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => { +test("enqueues a pricing refresh and exposes seller-scoped job status", async (testContext) => { const app = buildApp({ - authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }), authorizeUnsafeRequest: async () => {}, pricingService: { - async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; }, - async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; }, + async enqueueRefresh(input) { + return { id: "job-1", status: "PENDING", createdAt: input.sellerId }; + }, + async getJob(input) { + assert.equal(input.sellerId, "seller-1"); + return { id: input.jobId, status: "COMPLETED", error: null }; + }, }, }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/inventory/inventory-1/pricing/refresh', - headers: { 'x-csrf-token': 'csrf' }, - payload: { providerId: 'missing' }, + method: "POST", + url: "/api/v1/admin/inventory/inventory-1/pricing/refresh", + headers: { "x-csrf-token": "csrf" }, + payload: { providerId: "missing" }, }); assert.equal(response.statusCode, 202); - assert.equal(response.json().data.status, 'PENDING'); + assert.equal(response.json().data.status, "PENDING"); - const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' }); + const jobResponse = await app.inject({ + method: "GET", + url: "/api/v1/admin/jobs/job-1", + }); assert.equal(jobResponse.statusCode, 200); - assert.equal(jobResponse.json().data.status, 'COMPLETED'); + assert.equal(jobResponse.json().data.status, "COMPLETED"); }); -test('returns a conflict when refresh has no active product mapping', async (testContext) => { +test("returns a conflict when refresh has no active product mapping", async (testContext) => { const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ sellerId: "seller-1" }), authorizeUnsafeRequest: async () => {}, pricingService: { async enqueueRefresh() { - const error = new Error('No active pricing provider mapping exists for this product.'); - error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND'; + const error = new Error( + "No active pricing provider mapping exists for this product.", + ); + error.code = "PRICING_PROVIDER_MAPPING_NOT_FOUND"; error.statusCode = 409; throw error; }, @@ -88,30 +132,53 @@ test('returns a conflict when refresh has no active product mapping', async (tes testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/inventory/item-1/pricing/refresh', - headers: { 'x-csrf-token': 'csrf' }, - payload: { productId: 'product-1', conditionId: 'condition-1' }, + method: "POST", + url: "/api/v1/admin/inventory/item-1/pricing/refresh", + headers: { "x-csrf-token": "csrf" }, + payload: { productId: "product-1", conditionId: "condition-1" }, }); assert.equal(response.statusCode, 409); - assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND'); + assert.equal( + response.json().error.code, + "PRICING_PROVIDER_MAPPING_NOT_FOUND", + ); }); -test('lists jobs and retries a failed job with CSRF protection', async (testContext) => { +test("lists jobs and retries a failed job with CSRF protection", async (testContext) => { let csrfChecks = 0; const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), - authorizeUnsafeRequest: async () => { csrfChecks += 1; }, + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + authorizeUnsafeRequest: async () => { + csrfChecks += 1; + }, pricingService: { - async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; }, - async retryJob() { return { id: 'job-1', status: 'PENDING' }; }, + async listJobs() { + return [ + { + id: "job-1", + status: "FAILED", + providerId: "fixture", + inventoryItemId: "item-1", + }, + ]; + }, + async retryJob() { + return { id: "job-1", status: "PENDING" }; + }, }, }); testContext.after(() => app.close()); - const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' }); - const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } }); + const listResponse = await app.inject({ + method: "GET", + url: "/api/v1/admin/jobs", + }); + const retryResponse = await app.inject({ + method: "POST", + url: "/api/v1/admin/jobs/job-1/retry", + headers: { "x-csrf-token": "csrf" }, + }); assert.equal(listResponse.statusCode, 200); assert.equal(retryResponse.statusCode, 202); assert.equal(csrfChecks, 1); -}); \ No newline at end of file +}); diff --git a/apps/api/tests/http/provider-mapping-routes.test.js b/apps/api/tests/http/provider-mapping-routes.test.js index 86e9ca1..9658a88 100644 --- a/apps/api/tests/http/provider-mapping-routes.test.js +++ b/apps/api/tests/http/provider-mapping-routes.test.js @@ -1,29 +1,40 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; -test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => { +test("protects provider mapping writes and scopes mappings to the seller", async (testContext) => { let received; const app = buildApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ sellerId: "seller-1" }), authorizeUnsafeRequest: async () => {}, providerMappingService: { - async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; }, - async list() { return []; }, - async disable() { return { id: 'mapping-1', status: 'DISABLED' }; }, + async create(input) { + received = input; + return { id: "mapping-1", ...input, status: "ACTIVE" }; + }, + async list() { + return []; + }, + async disable() { + return { id: "mapping-1", status: "DISABLED" }; + }, }, }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/products/product-1/provider-mappings', - headers: { 'x-csrf-token': 'csrf' }, - payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }, + method: "POST", + url: "/api/v1/admin/products/product-1/provider-mappings", + headers: { "x-csrf-token": "csrf" }, + payload: { + providerId: "fixture", + mappingType: "PRODUCT", + externalIdentifier: "ext-1", + }, }); assert.equal(response.statusCode, 201); - assert.equal(received.sellerId, 'seller-1'); - assert.equal(response.json().data.providerId, 'fixture'); + assert.equal(received.sellerId, "seller-1"); + assert.equal(response.json().data.providerId, "fixture"); }); diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js index 1d997d0..cdc7505 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -1,19 +1,19 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; -test('returns public search results with pagination metadata', async (testContext) => { +test("returns public search results with pagination metadata", async (testContext) => { const app = buildApp({ searchService: { async searchPublic(filters) { assert.equal(filters.page, 2); assert.equal(filters.limit, 5); assert.equal(filters.includeSold, true); - assert.equal(filters.sort, 'price_low'); - assert.equal(filters.conditionId, 'condition-1'); + assert.equal(filters.sort, "price_low"); + assert.equal(filters.conditionId, "condition-1"); return { - items: [{ id: 'listing-1', status: 'CLOSED' }], + items: [{ id: "listing-1", status: "CLOSED" }], pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, }; }, @@ -22,8 +22,8 @@ test('returns public search results with pagination metadata', async (testContex testContext.after(() => app.close()); const response = await app.inject({ - method: 'GET', - url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1', + method: "GET", + url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1", }); assert.equal(response.statusCode, 200); @@ -33,44 +33,71 @@ test('returns public search results with pagination metadata', async (testContex total: 6, totalPages: 2, }); - assert.equal(response.json().data.items[0].id, 'listing-1'); + assert.equal(response.json().data.items[0].id, "listing-1"); }); -test('rejects malformed numeric search filters', async (testContext) => { - const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } }); +test("rejects malformed numeric search filters", async (testContext) => { + const app = buildApp({ + searchService: { + async searchPublic() { + throw new Error("should not run"); + }, + }, + }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'GET', - url: '/api/v1/public/search?minPriceMinor=12.5', + method: "GET", + url: "/api/v1/public/search?minPriceMinor=12.5", }); assert.equal(response.statusCode, 400); - assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER'); + assert.equal(response.json().error.code, "INVALID_SEARCH_FILTER"); }); -test('returns active public category options', async (testContext) => { +test("returns active public category options", async (testContext) => { const app = buildApp({ catalogLookupRepository: { - async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; }, + async listCategories() { + return [{ id: "category-1", name: "Trading Cards" }]; + }, }, }); testContext.after(() => app.close()); - const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/public/lookups/categories", + }); assert.equal(response.statusCode, 200); - assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]); + assert.deepEqual(response.json().data, [ + { id: "category-1", name: "Trading Cards" }, + ]); }); -test('returns public condition options without category internals', async (testContext) => { +test("returns public condition options without category internals", async (testContext) => { const app = buildApp({ catalogLookupRepository: { - async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; }, + async listConditions() { + return [ + { + id: "condition-1", + name: "Near Mint", + code: "NM", + categoryId: "private-category", + }, + ]; + }, }, }); testContext.after(() => app.close()); - const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/public/lookups/conditions", + }); assert.equal(response.statusCode, 200); - assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]); -}); \ No newline at end of file + assert.deepEqual(response.json().data, [ + { id: "condition-1", name: "Near Mint", code: "NM" }, + ]); +}); diff --git a/apps/api/tests/http/sale-routes.test.js b/apps/api/tests/http/sale-routes.test.js index 06c7ff5..92bc346 100644 --- a/apps/api/tests/http/sale-routes.test.js +++ b/apps/api/tests/http/sale-routes.test.js @@ -1,15 +1,15 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import assert from "node:assert/strict"; +import test from "node:test"; -import { buildApp } from '../../src/app/build-app.js'; +import { buildApp } from "../../src/app/build-app.js"; const saleResult = { sale: { - id: 'sale-1', - sellerId: 'seller-1', - saleDate: '2026-09-09', + id: "sale-1", + sellerId: "seller-1", + saleDate: "2026-09-09", soldPriceMinor: 2499, - currency: 'USD', + currency: "USD", platformFeesMinor: 0, paymentProcessingFeesMinor: 0, shippingCostMinor: 0, @@ -17,12 +17,12 @@ const saleResult = { otherFeesMinor: 0, }, saleItem: { - id: 'sale-item-1', - inventoryItemId: 'inventory-1', + id: "sale-item-1", + inventoryItemId: "inventory-1", soldPriceMinor: 2499, costBasisMinor: 1200, }, - inventoryItem: { id: 'inventory-1', status: 'SOLD' }, + inventoryItem: { id: "inventory-1", status: "SOLD" }, }; function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { @@ -31,70 +31,74 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { authorizeUnsafeRequest, saleService: { async create(input) { - assert.equal(input.sellerId, 'seller-1'); + assert.equal(input.sellerId, "seller-1"); return saleResult; }, }, }); } -test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => { +test("creates a sale for the authenticated seller with CSRF protection", async (testContext) => { let csrfChecks = 0; const app = createTestApp({ - authenticateAdmin: async () => ({ sellerId: 'seller-1' }), - authorizeUnsafeRequest: async () => { csrfChecks += 1; }, + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + authorizeUnsafeRequest: async () => { + csrfChecks += 1; + }, }); testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/sales', - headers: { 'x-csrf-token': 'csrf-token' }, + method: "POST", + url: "/api/v1/admin/sales", + headers: { "x-csrf-token": "csrf-token" }, payload: { - inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', + inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8", costBasisMinor: 1200, - saleDate: '2026-09-09', + saleDate: "2026-09-09", soldPriceMinor: 2499, }, }); assert.equal(response.statusCode, 201); - assert.equal(response.json().data.inventoryItem.status, 'SOLD'); + assert.equal(response.json().data.inventoryItem.status, "SOLD"); assert.equal(csrfChecks, 1); }); -test('rejects invalid sale requests and unauthenticated requests', async (testContext) => { - const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); +test("rejects invalid sale requests and unauthenticated requests", async (testContext) => { + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: "seller-1" }), + }); testContext.after(() => app.close()); const invalidResponse = await app.inject({ - method: 'POST', - url: '/api/v1/admin/sales', + method: "POST", + url: "/api/v1/admin/sales", payload: { - inventoryItemId: 'not-a-uuid', + inventoryItemId: "not-a-uuid", costBasisMinor: -1, - saleDate: 'tomorrow', + saleDate: "tomorrow", soldPriceMinor: 0, }, }); assert.equal(invalidResponse.statusCode, 400); - assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR'); + assert.equal(invalidResponse.json().error.code, "VALIDATION_ERROR"); const unauthenticatedApp = createTestApp(); testContext.after(() => unauthenticatedApp.close()); const unauthenticatedResponse = await unauthenticatedApp.inject({ - method: 'POST', - url: '/api/v1/admin/sales', + method: "POST", + url: "/api/v1/admin/sales", payload: {}, }); assert.equal(unauthenticatedResponse.statusCode, 401); - assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED'); + assert.equal(unauthenticatedResponse.json().error.code, "UNAUTHORIZED"); }); -test('voids a sale with CSRF protection and actor identity', async (testContext) => { +test("voids a sale with CSRF protection and actor identity", async (testContext) => { let voidInput; const app = buildApp({ - authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), + authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }), authorizeUnsafeRequest: async () => {}, saleService: { async void(input) { @@ -106,13 +110,13 @@ test('voids a sale with CSRF protection and actor identity', async (testContext) testContext.after(() => app.close()); const response = await app.inject({ - method: 'POST', - url: '/api/v1/admin/sales/sale-1/void', - headers: { 'x-csrf-token': 'csrf-token' }, - payload: { reason: 'Duplicate entry' }, + method: "POST", + url: "/api/v1/admin/sales/sale-1/void", + headers: { "x-csrf-token": "csrf-token" }, + payload: { reason: "Duplicate entry" }, }); assert.equal(response.statusCode, 200); - assert.equal(voidInput.actorUserId, 'user-1'); - assert.equal(response.json().data.inventoryItem.status, 'SOLD'); -}); \ No newline at end of file + assert.equal(voidInput.actorUserId, "user-1"); + assert.equal(response.json().data.inventoryItem.status, "SOLD"); +}); diff --git a/apps/api/tests/integration/transactions-pricing-sales.test.js b/apps/api/tests/integration/transactions-pricing-sales.test.js index c34d7f4..4612992 100644 --- a/apps/api/tests/integration/transactions-pricing-sales.test.js +++ b/apps/api/tests/integration/transactions-pricing-sales.test.js @@ -1,9 +1,9 @@ -import assert from 'node:assert/strict'; -import test, { after, beforeEach } from 'node:test'; +import assert from "node:assert/strict"; +import test, { after, beforeEach } from "node:test"; -import { sql } from 'drizzle-orm'; +import { sql } from "drizzle-orm"; -import { createDatabase } from '../../src/infrastructure/database/client.js'; +import { createDatabase } from "../../src/infrastructure/database/client.js"; import { categories, conditionProfiles, @@ -13,76 +13,103 @@ import { securityAuditEvents, sellers, users, -} from '../../src/infrastructure/database/schema.js'; -import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js'; -import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js'; -import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js'; -import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js'; -import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js'; +} from "../../src/infrastructure/database/schema.js"; +import { createAcquisitionRepository } from "../../src/modules/acquisitions/repositories/acquisition-repository.js"; +import { createPricingRepository } from "../../src/modules/pricing/repositories/pricing-repository.js"; +import { createPricingRefreshJobRepository } from "../../src/modules/pricing/repositories/pricing-refresh-job-repository.js"; +import { createProviderMappingRepository } from "../../src/modules/pricing/repositories/provider-mapping-repository.js"; +import { createSaleRepository } from "../../src/modules/sales/repositories/sale-repository.js"; const connectionString = process.env.TEST_DATABASE_URL; if (!connectionString) { - throw new Error('TEST_DATABASE_URL is required to run integration tests.'); + throw new Error("TEST_DATABASE_URL is required to run integration tests."); } -if (!new URL(connectionString).pathname.endsWith('_test')) { - throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".'); +if (!new URL(connectionString).pathname.endsWith("_test")) { + throw new Error( + 'TEST_DATABASE_URL must reference a database whose name ends in "_test".', + ); } const database = createDatabase(connectionString); const acquisitionRepository = createAcquisitionRepository(database.db); const pricingRepository = createPricingRepository(database.db); -const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); +const pricingRefreshJobRepository = createPricingRefreshJobRepository( + database.db, +); const providerMappingRepository = createProviderMappingRepository(database.db); const saleRepository = createSaleRepository(database.db); async function createFixture() { - const [user] = await database.db.insert(users).values({ - email: 'transactions@example.test', - passwordHash: 'not-a-real-password', - }).returning(); - const [seller] = await database.db.insert(sellers).values({ - userId: user.id, - displayName: 'Transactions Owner', - }).returning(); - const [category] = await database.db.insert(categories).values({ - name: 'Transactions Cards', - slug: 'transactions-cards', - }).returning(); - const [profile] = await database.db.insert(conditionProfiles).values({ - categoryId: category.id, - name: 'Card', - }).returning(); - const [condition] = await database.db.insert(conditions).values({ - conditionProfileId: profile.id, - name: 'Near Mint', - code: 'NM', - qualityRank: 1, - }).returning(); - const [product] = await database.db.insert(products).values({ - sellerId: seller.id, - categoryId: category.id, - name: 'Integration Card', - slug: 'integration-card', - }).returning(); - const inventory = await database.db.insert(inventoryItems).values([ - { productId: product.id, conditionId: condition.id }, - { productId: product.id, conditionId: condition.id }, - ]).returning(); + const [user] = await database.db + .insert(users) + .values({ + email: "transactions@example.test", + passwordHash: "not-a-real-password", + }) + .returning(); + const [seller] = await database.db + .insert(sellers) + .values({ + userId: user.id, + displayName: "Transactions Owner", + }) + .returning(); + const [category] = await database.db + .insert(categories) + .values({ + name: "Transactions Cards", + slug: "transactions-cards", + }) + .returning(); + const [profile] = await database.db + .insert(conditionProfiles) + .values({ + categoryId: category.id, + name: "Card", + }) + .returning(); + const [condition] = await database.db + .insert(conditions) + .values({ + conditionProfileId: profile.id, + name: "Near Mint", + code: "NM", + qualityRank: 1, + }) + .returning(); + const [product] = await database.db + .insert(products) + .values({ + sellerId: seller.id, + categoryId: category.id, + name: "Integration Card", + slug: "integration-card", + }) + .returning(); + const inventory = await database.db + .insert(inventoryItems) + .values([ + { productId: product.id, conditionId: condition.id }, + { productId: product.id, conditionId: condition.id }, + ]) + .returning(); return { user, seller, product, condition, inventory }; } beforeEach(async () => { - await database.db.execute(sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`); + await database.db.execute( + sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`, + ); }); after(async () => { await database.close(); }); -test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => { +test("persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally", async () => { const { user, seller, product, condition, inventory } = await createFixture(); const [firstItem, secondItem] = inventory; @@ -90,47 +117,50 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even sellerId: seller.id, acquisition: { inventoryItemId: firstItem.id, - method: 'PURCHASE', + method: "PURCHASE", costMinor: 1000, - currency: 'USD', + currency: "USD", shippingMinor: 100, taxMinor: 50, otherFeesMinor: 0, estimatedTradeValueMinor: 0, }, }); - assert.equal(acquisition.status, 'CREATED'); + assert.equal(acquisition.status, "CREATED"); - for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) { + for (const [item, price] of [ + [firstItem, 2500], + [secondItem, 3000], + ]) { const observation = await pricingRepository.addObservationForSeller({ sellerId: seller.id, observation: { productId: product.id, inventoryItemId: item.id, - providerId: 'integration', + providerId: "integration", priceMinor: price, - currency: 'USD', + currency: "USD", sourceIdentifier: `source-${item.id}`, conditionId: condition.id, }, }); - assert.equal(observation.status, 'CREATED'); + assert.equal(observation.status, "CREATED"); } const valuation = await pricingRepository.calculateForSeller({ sellerId: seller.id, inventoryItemId: firstItem.id, }); - assert.equal(valuation.status, 'CALCULATED'); + assert.equal(valuation.status, "CALCULATED"); assert.equal(valuation.valuation.valueMinor, 2500); assert.equal(valuation.valuation.sourceCount, 1); const sale = await saleRepository.createForInventoryItems({ sale: { sellerId: seller.id, - saleDate: '2026-09-09', + saleDate: "2026-09-09", soldPriceMinor: 5500, - currency: 'USD', + currency: "USD", platformFeesMinor: 200, paymentProcessingFeesMinor: 0, shippingCostMinor: 0, @@ -138,58 +168,92 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even otherFeesMinor: 0, }, items: [ - { inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 }, - { inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 }, + { + inventoryItemId: firstItem.id, + soldPriceMinor: 2500, + costBasisMinor: 1150, + }, + { + inventoryItemId: secondItem.id, + soldPriceMinor: 3000, + costBasisMinor: 900, + }, ], }); - assert.equal(sale.status, 'CREATED'); + assert.equal(sale.status, "CREATED"); assert.equal(sale.saleItems.length, 2); - assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']); + assert.deepEqual( + sale.inventoryItems.map(({ status }) => status), + ["SOLD", "SOLD"], + ); const voided = await saleRepository.voidForSeller({ saleId: sale.sale.id, sellerId: seller.id, actorUserId: user.id, - reason: 'Integration correction', + reason: "Integration correction", }); - assert.equal(voided.status, 'VOIDED'); - assert.equal(voided.sale.status, 'VOIDED'); + assert.equal(voided.status, "VOIDED"); + assert.equal(voided.sale.status, "VOIDED"); assert.equal(voided.saleItems.length, 2); - const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`); + const [auditEvent] = await database.db + .select() + .from(securityAuditEvents) + .where(sql`event = 'SALE_VOIDED'`); assert.equal(auditEvent.userId, user.id); }); -test('claims and completes a durable pricing refresh job', async () => { +test("claims and completes a durable pricing refresh job", async () => { const { user, seller, product, condition, inventory } = await createFixture(); - const [job] = await pricingRefreshJobRepository.create({ - sellerId: seller.id, - inventoryItemId: inventory[0].id, - productId: product.id, - conditionId: condition.id, - providerId: 'integration', - }).then((created) => [created]); + const [job] = await pricingRefreshJobRepository + .create({ + sellerId: seller.id, + inventoryItemId: inventory[0].id, + productId: product.id, + conditionId: condition.id, + providerId: "integration", + }) + .then((created) => [created]); const claimed = await pricingRefreshJobRepository.claimNext(); assert.equal(claimed.id, job.id); - assert.equal(claimed.status, 'PROCESSING'); - const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 }); - assert.equal(completed.status, 'COMPLETED'); + assert.equal(claimed.status, "PROCESSING"); + const completed = await pricingRefreshJobRepository.complete( + job.id, + [{ priceMinor: 2500 }], + { valueMinor: 2500 }, + ); + assert.equal(completed.status, "COMPLETED"); assert.deepEqual(completed.valuation, { valueMinor: 2500 }); - assert.equal(user.email, 'transactions@example.test'); + assert.equal(user.email, "transactions@example.test"); }); -test('persists and disables seller-owned provider mappings', async () => { +test("persists and disables seller-owned provider mappings", async () => { const { seller, product } = await createFixture(); - const created = await providerMappingRepository.createForSeller({ - productId: product.id, - providerId: 'integration', - mappingType: 'PRODUCT', - externalIdentifier: 'catalog-1', - }, seller.id); - assert.equal(created.status, 'CREATED'); - assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1); - const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id); - assert.equal(disabled.status, 'DISABLED'); - assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0); + const created = await providerMappingRepository.createForSeller( + { + productId: product.id, + providerId: "integration", + mappingType: "PRODUCT", + externalIdentifier: "catalog-1", + }, + seller.id, + ); + assert.equal(created.status, "CREATED"); + assert.equal( + (await providerMappingRepository.listForSeller(product.id, seller.id)) + .length, + 1, + ); + const disabled = await providerMappingRepository.disableForSeller( + created.mapping.id, + seller.id, + ); + assert.equal(disabled.status, "DISABLED"); + assert.equal( + (await providerMappingRepository.listForSeller(product.id, seller.id)) + .length, + 0, + ); }); diff --git a/apps/web/src/components/molecules/ImageGallery.stories.js b/apps/web/src/components/molecules/ImageGallery.stories.js index ffdcf20..e2392a1 100644 --- a/apps/web/src/components/molecules/ImageGallery.stories.js +++ b/apps/web/src/components/molecules/ImageGallery.stories.js @@ -1,11 +1,11 @@ -import ImageGallery from './ImageGallery.vue'; +import ImageGallery from "./ImageGallery.vue"; -export default { title: 'Molecules/ImageGallery', component: ImageGallery }; +export default { title: "Molecules/ImageGallery", component: ImageGallery }; export const ContainedMuseumStage = { args: { images: [ - { id: 'front', label: 'FRONT', alt: 'Front of Charizard ex' }, - { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, + { id: "front", label: "FRONT", alt: "Front of Charizard ex" }, + { id: "back", label: "BACK", alt: "Back of Charizard ex" }, ], }, }; @@ -13,21 +13,37 @@ export const ContainedMuseumStage = { export const WithPublicImageUrls = { args: { images: [ - { id: 'front', src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', alt: 'Front of Charizard ex' }, - { id: 'back', label: 'BACK', alt: 'Back of Charizard ex' }, + { + id: "front", + src: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="640" height="480"%3E%3Crect width="100%" height="100%" fill="%23d9e2ef"/%3E%3Ctext x="50%" y="50%" text-anchor="middle" fill="%231761ff" font-size="42"%3EFRONT%3C/text%3E%3C/svg%3E', + alt: "Front of Charizard ex", + }, + { id: "back", label: "BACK", alt: "Back of Charizard ex" }, ], }, }; export const BrokenImageFallback = { args: { - images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }], + images: [ + { + id: "broken", + src: "/fixtures/missing-image.webp", + alt: "Unavailable collectible image", + }, + ], }, }; export const LoadingImage = { args: { - images: [{ id: 'loading', src: '/fixtures/slow-image.webp', alt: 'Collectible image loading' }], + images: [ + { + id: "loading", + src: "/fixtures/slow-image.webp", + alt: "Collectible image loading", + }, + ], }, parameters: { chromatic: { delay: 1000 } }, -}; \ No newline at end of file +}; diff --git a/apps/web/src/components/molecules/ImageGallery.vue b/apps/web/src/components/molecules/ImageGallery.vue index fd12355..648e965 100644 --- a/apps/web/src/components/molecules/ImageGallery.vue +++ b/apps/web/src/components/molecules/ImageGallery.vue @@ -1,5 +1,5 @@ \ No newline at end of file + diff --git a/apps/web/src/pages/ListingDetailPage.vue b/apps/web/src/pages/ListingDetailPage.vue index 583236f..6a427ec 100644 --- a/apps/web/src/pages/ListingDetailPage.vue +++ b/apps/web/src/pages/ListingDetailPage.vue @@ -1,9 +1,9 @@