Compare commits

...
79 Commits
Author SHA1 Message Date
fragginwagon 3132e80646 fix: label image actions by item
Quality / test (push) Canceled after 0s
2026-09-13 17:55:02 -04:00
fragginwagon a81f882831 test: cover image upload guidance
Quality / test (push) Canceled after 0s
2026-09-13 17:53:31 -04:00
fragginwagon 6840c9e6ba fix: clarify image upload constraints
Quality / test (push) Canceled after 0s
2026-09-13 17:40:40 -04:00
fragginwagon 3dbb2e8001 fix: clarify listing review fields
Quality / test (push) Canceled after 0s
2026-09-13 17:39:11 -04:00
fragginwagon 312b1e5bbc fix: clarify product and copy fields
Quality / test (push) Canceled after 0s
2026-09-11 15:52:30 -04:00
fragginwagon cc45a4f119 fix: clarify mapping and pricing fields
Quality / test (push) Canceled after 0s
2026-09-11 15:41:08 -04:00
fragginwagon 7c6e00fca3 fix: clarify admin transaction amounts
Quality / test (push) Canceled after 0s
2026-09-11 15:40:19 -04:00
fragginwagon 255848fcaa test: cover session expiry messaging
Quality / test (push) Canceled after 0s
2026-09-11 15:31:52 -04:00
fragginwagon f1190fa806 feat: explain expired admin sessions
Quality / test (push) Canceled after 0s
2026-09-11 13:24:27 -04:00
fragginwagon 7786671c00 fix: expose logout progress
Quality / test (push) Canceled after 0s
2026-09-11 12:36:09 -04:00
fragginwagon 0f04ef45fe test: cover login error focus
Quality / test (push) Canceled after 0s
2026-09-11 12:14:11 -04:00
fragginwagon 38406f7c60 fix: focus login errors
Quality / test (push) Canceled after 0s
2026-09-11 11:51:59 -04:00
fragginwagon e3db8df92e fix: improve settings and saved view accessibility
Quality / test (push) Canceled after 0s
2026-09-11 11:28:12 -04:00
fragginwagon fef7c0a339 fix: improve listing status accessibility
Quality / test (push) Canceled after 0s
2026-09-11 10:58:30 -04:00
fragginwagon 4217009384 fix: label admin transaction history
Quality / test (push) Canceled after 0s
2026-09-11 09:21:54 -04:00
fragginwagon bb8499e10c fix: label admin inventory results
Quality / test (push) Canceled after 0s
2026-09-11 09:21:00 -04:00
fragginwagon 18a99595e5 test: cover inventory action announcements
Quality / test (push) Canceled after 0s
2026-09-11 09:20:17 -04:00
fragginwagon 98d2adac17 feat: announce admin inventory actions
Quality / test (push) Canceled after 0s
2026-09-11 09:18:19 -04:00
fragginwagon eb43cef872 feat: add admin navigation skip link
Quality / test (push) Canceled after 0s
2026-09-11 09:17:18 -04:00
fragginwagon f54223966c feat: enforce per-item image count limits
Quality / test (push) Canceled after 0s
2026-09-11 00:39:27 -04:00
fragginwagon e6510c0317 fix: inspect uploaded image dimensions on the server
Quality / test (push) Canceled after 0s
2026-09-11 00:35:07 -04:00
fragginwagon cce395baf6 fix: validate image order and alt text updates
Quality / test (push) Canceled after 0s
2026-09-11 00:18:11 -04:00
fragginwagon 0e81fc4022 feat: reorder inventory images atomically
Quality / test (push) Canceled after 0s
2026-09-11 00:13:44 -04:00
fragginwagon 72e9a79b67 fix: preserve image manager state during action failures
Quality / test (push) Canceled after 0s
2026-09-10 17:57:28 -04:00
fragginwagon c5a07b2b5a fix: recover from saved view load and mutation failures
Quality / test (push) Canceled after 0s
2026-09-10 17:49:39 -04:00
fragginwagon d321d8c679 fix: recover safely from settings load and save failures
Quality / test (push) Canceled after 0s
2026-09-10 17:44:53 -04:00
fragginwagon 9a790ce294 fix: allow sellers to remove public contact methods
Quality / test (push) Canceled after 0s
2026-09-10 07:56:17 -04:00
fragginwagon 73c430b8a5 feat: preserve focus and catalog context during public navigation
Quality / test (push) Canceled after 0s
2026-09-10 07:53:03 -04:00
fragginwagon a02965de4a fix: improve public detail keyboard access and reflow
Quality / test (push) Canceled after 0s
2026-09-10 07:48:42 -04:00
fragginwagon 94c37250d1 feat: configure and display public seller descriptions
Quality / test (push) Canceled after 0s
2026-09-10 07:40:18 -04:00
fragginwagon e630a89e8d feat: surface seller contact methods on public detail pages
Quality / test (push) Canceled after 0s
2026-09-10 07:16:41 -04:00
fragginwagon 54a8d87293 fix: correct interactive text contrast and add detail page stories
Quality / test (push) Canceled after 0s
2026-09-10 07:09:54 -04:00
fragginwagon 6b528350c0 fix: correct catalog error contrast and add catalog Storybook coverage
Quality / test (push) Canceled after 0s
2026-09-10 07:04:40 -04:00
fragginwagon c85693c919 feat: extract dedicated admin inventory management screen
Quality / test (push) Canceled after 0s
2026-09-10 06:46:11 -04:00
fragginwagon 934aad2afd feat: manage focus and announce steps in guided wizard
Quality / test (push) Canceled after 0s
2026-09-10 06:37:02 -04:00
fragginwagon 1fa4bb1c7a feat: extract dedicated admin sales and acquisitions screens
Quality / test (push) Canceled after 0s
2026-09-10 06:32:31 -04:00
fragginwagon 6c99ffaeba feat: extract dedicated admin listings management screen
Quality / test (push) Canceled after 0s
2026-09-10 06:28:35 -04:00
fragginwagon 54be51ed5d feat: build true guided inventory workflow wizard
Quality / test (push) Canceled after 0s
2026-09-10 06:23:51 -04:00
fragginwagon c9f6a58516 feat: rebuild admin image manager per-image controls
Quality / test (push) Canceled after 0s
2026-09-10 06:15:51 -04:00
fragginwagon 7d20aedd14 feat: improve public listing detail states
Quality / test (push) Canceled after 0s
2026-09-10 06:09:13 -04:00
fragginwagon ae1605c82d feat: extract review and list workflow step
Quality / test (push) Canceled after 0s
2026-09-10 06:03:48 -04:00
fragginwagon 82b012faa1 feat: extract pricing review workflow step
Quality / test (push) Canceled after 0s
2026-09-10 06:01:37 -04:00
fragginwagon ff6f3359aa feat: extract inventory images workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:25:33 -04:00
fragginwagon 13702bb3ef feat: extract inventory copy workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:13:17 -04:00
fragginwagon 7ad5635900 feat: extract provider mapping workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:11:19 -04:00
fragginwagon 3e29ca0529 feat: extract product identity workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:09:39 -04:00
fragginwagon 8ff547feaa feat: add inventory workflow stepper
Quality / test (push) Canceled after 0s
2026-09-10 03:07:51 -04:00
fragginwagon ffb86ce21a feat: improve mobile catalog filters
Quality / test (push) Canceled after 0s
2026-09-10 03:05:49 -04:00
fragginwagon 81705af6e3 feat: add market value catalog sorting
Quality / test (push) Canceled after 0s
2026-09-10 03:04:10 -04:00
fragginwagon c9ad3a11af feat: add catalog status filters
Quality / test (push) Canceled after 0s
2026-09-10 03:02:23 -04:00
fragginwagon 673701fa7a feat: expand catalog filters
Quality / test (push) Canceled after 0s
2026-09-10 03:00:20 -04:00
fragginwagon b036092775 feat: expand catalog sorting
Quality / test (push) Canceled after 0s
2026-09-10 02:39:42 -04:00
fragginwagon 0008af9f2c feat: enforce deployment environment preflight
Quality / test (push) Canceled after 0s
2026-09-10 02:32:43 -04:00
fragginwagon 96d3bf831e feat: add production environment preflight
Quality / test (push) Canceled after 0s
2026-09-10 02:30:45 -04:00
fragginwagon ea6a839b69 docs: refresh implementation status
Quality / test (push) Canceled after 0s
2026-09-10 02:25:06 -04:00
fragginwagon 1e270f6184 ci: cover worker and deployment gates
Quality / test (push) Canceled after 0s
2026-09-10 02:23:22 -04:00
fragginwagon e069dcaa40 fix: wait for healthy deployment services
Quality / test (push) Canceled after 0s
2026-09-10 02:09:25 -04:00
fragginwagon 0cd1b9ff2c docs: sync release readiness status
Quality / test (push) Canceled after 0s
2026-09-10 02:06:25 -04:00
fragginwagon fce30513d4 test: expand authenticated admin workflows
Quality / test (push) Canceled after 0s
2026-09-10 02:01:00 -04:00
fragginwagon 4f0e3d89f6 feat: enforce production pricing provider config
Quality / test (push) Canceled after 0s
2026-09-10 01:46:58 -04:00
fragginwagon f837324cf5 docs: update implementation status
Quality / test (push) Canceled after 0s
2026-09-10 01:42:49 -04:00
fragginwagon ffd239d27c feat: add seller saved views
Quality / test (push) Canceled after 0s
2026-09-10 01:36:15 -04:00
fragginwagon ec322df25c feat: download completed export jobs
Quality / test (push) Canceled after 0s
2026-09-10 01:31:09 -04:00
fragginwagon ad8f675f5e fix: register inventory export jobs schema
Quality / test (push) Canceled after 0s
2026-09-10 01:25:20 -04:00
fragginwagon 7c4885cf65 feat: add asynchronous inventory exports
Quality / test (push) Canceled after 0s
2026-09-10 01:25:11 -04:00
fragginwagon abb0b64217 fix: export settings validation schema
Quality / test (push) Canceled after 0s
2026-09-10 01:15:08 -04:00
fragginwagon cf61ec3c2c feat: add seller settings
Quality / test (push) Canceled after 0s
2026-09-10 01:14:57 -04:00
fragginwagon 24f51212e5 feat: add admin inventory Excel export
Quality / test (push) Canceled after 0s
2026-09-10 01:11:07 -04:00
fragginwagon d6b4dbec5f feat: add Product tags and bulk tag updates
Quality / test (push) Canceled after 0s
2026-09-10 00:51:05 -04:00
fragginwagon 7c9518923d feat: add admin inventory CSV export
Quality / test (push) Canceled after 0s
2026-09-10 00:31:00 -04:00
fragginwagon d3821e9c76 feat: add admin inventory JSON export
Quality / test (push) Canceled after 0s
2026-09-10 00:16:04 -04:00
fragginwagon 099ec2fe72 feat: add bulk pricing refresh
Quality / test (push) Canceled after 0s
2026-09-10 00:07:29 -04:00
fragginwagon e0d231facc feat: expose bulk archive in admin workspace
Quality / test (push) Canceled after 0s
2026-09-09 23:48:34 -04:00
fragginwagon 12eced8b19 feat: add seller-scoped bulk archive
Quality / test (push) Canceled after 0s
Add the first bulk inventory operation through a validated, CSRF-protected API route with lifecycle and ownership enforcement, tests, and implementation-status documentation.
2026-09-09 23:11:48 -04:00
fragginwagon aaa9e2a370 fix: require https for external pricing providers
Quality / test (push) Canceled after 0s
Require HTTPS-only external pricing-provider URLs and verify the adapter contract with the application suite.
2026-09-09 22:42:35 -04:00
fragginwagon 79df9d5c88 feat: add restore drill coverage
Quality / test (push) Canceled after 0s
Add restore verification and image-storage recovery scripts, update deployment documentation, and note the restore drill milestone in the implementation status.
2026-09-09 22:35:25 -04:00
fragginwagon 76e40d7289 feat: schedule pricing job retention cleanup
Quality / test (push) Canceled after 0s
Schedule configurable completed pricing-job cleanup in the worker, broaden repository cleanup across sellers, add unit and integration coverage, and update pricing operational documentation.
2026-09-09 22:32:04 -04:00
fragginwagon 660690bf34 chore: reconcile ci deployment configuration
Quality / test (push) Canceled after 0s
2026-09-09 22:13:28 -04:00
fragginwagon b8f089a5d9 feat: reconcile application vertical slices
Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
2026-09-09 22:12:53 -04:00
229 changed files with 10650 additions and 2338 deletions
+2
View File
@@ -9,6 +9,8 @@ PRICING_FIXTURE_PROVIDER_ENABLED=false
PRICING_HTTP_PROVIDER_ID=http-provider PRICING_HTTP_PROVIDER_ID=http-provider
PRICING_HTTP_PROVIDER_ENDPOINT= PRICING_HTTP_PROVIDER_ENDPOINT=
PRICING_HTTP_PROVIDER_TOKEN= PRICING_HTTP_PROVIDER_TOKEN=
PRICING_JOB_RETENTION_DAYS=30
PRICING_JOB_CLEANUP_INTERVAL_MS=3600000
PASSWORD_RESET_URL=http://localhost:5173/reset-password PASSWORD_RESET_URL=http://localhost:5173/reset-password
SMTP_FROM=no-reply@example.test SMTP_FROM=no-reply@example.test
SMTP_URL=smtp://localhost:1025 SMTP_URL=smtp://localhost:1025
+14 -1
View File
@@ -47,6 +47,19 @@ jobs:
DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
- run: npm test - run: npm test
- name: Run worker tests
run: npm run test --workspace=@pokemon-inventory/worker
- name: Audit production dependencies
run: npm audit --omit=dev
- name: Validate deployment scripts
run: |
sh -n scripts/*.sh
sh scripts/deployment-health-wait.test.sh
sh scripts/verify-postgres-backup.test.sh
sh scripts/restore-postgres.test.sh
sh scripts/restore-image-storage.test.sh
sh scripts/smoke-production.test.sh
sh scripts/validate-production-env.test.sh
- name: Run integration tests - name: Run integration tests
env: env:
TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test
@@ -71,4 +84,4 @@ jobs:
- run: npm run test --workspace=@pokemon-inventory/web - run: npm run test --workspace=@pokemon-inventory/web
- run: npm run build --workspace=@pokemon-inventory/web - run: npm run build --workspace=@pokemon-inventory/web
- run: npm run build-storybook --workspace=@pokemon-inventory/web - run: npm run build-storybook --workspace=@pokemon-inventory/web
- run: npm run test-storybook - run: npm run test-storybook
+1 -1
View File
@@ -132,7 +132,7 @@ With `npm run dev` running, configured local admin credentials, and demo lookup
npm run test:e2e npm run test:e2e
``` ```
The workflow covers login, admin overview loading, product creation, physical inventory creation, and navigation to image management. It skips when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured. The authenticated workflows cover login, admin overview loading, product and physical inventory creation, image management, listing publication, sales and audited sale correction, settings updates, Saved Views CRUD, and export controls. They skip when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured.
## Initial Administrator ## Initial Administrator
+59 -43
View File
@@ -1,32 +1,36 @@
import Fastify from 'fastify'; import Fastify from "fastify";
import cookie from '@fastify/cookie'; import cookie from "@fastify/cookie";
import multipart from '@fastify/multipart'; import multipart from "@fastify/multipart";
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js'; import { registerAuthRoutes } from "../modules/auth/routes/auth-routes.js";
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js'; import { registerAdminInventoryRoutes } from "../modules/inventory/routes/admin-inventory-routes.js";
import { registerAdminLookupRoutes } from '../modules/inventory/routes/admin-lookup-routes.js'; import { registerAdminLookupRoutes } from "../modules/inventory/routes/admin-lookup-routes.js";
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js'; import { registerAdminImageRoutes } from "../modules/inventory/routes/admin-image-routes.js";
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js'; import { registerPublicInventoryRoutes } from "../modules/inventory/routes/public-inventory-routes.js";
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js'; import { registerPublicImageRoutes } from "../modules/inventory/routes/public-image-routes.js";
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js'; import { registerAdminListingRoutes } from "../modules/listings/routes/admin-listing-routes.js";
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js'; import { registerPublicListingRoutes } from "../modules/listings/routes/public-listing-routes.js";
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js'; import { registerAdminProductRoutes } from "../modules/products/routes/admin-product-routes.js";
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js'; import { registerPublicProductRoutes } from "../modules/products/routes/public-product-routes.js";
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js'; import { registerPublicSearchRoutes } from "../modules/search/routes/public-search-routes.js";
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js'; import { registerPublicSellerRoutes } from "../modules/sellers/routes/public-seller-routes.js";
import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js'; import { registerPublicLookupRoutes } from "../modules/inventory/routes/public-lookup-routes.js";
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js'; import { registerAdminSaleRoutes } from "../modules/sales/routes/admin-sale-routes.js";
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/admin-acquisition-routes.js";
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js'; import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js'; import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js'; import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
import { registerAdminSavedViewRoutes } from "../modules/search/routes/admin-saved-view-routes.js";
export function buildApp(dependencies) { export function buildApp(dependencies) {
const app = Fastify(); const app = Fastify();
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {
const statusCode = error.statusCode ?? 500; const statusCode = error.statusCode ?? 500;
const message = statusCode >= 500 ? 'Internal server error.' : error.message; const message =
statusCode >= 500 ? "Internal server error." : error.message;
if (statusCode >= 500) { if (statusCode >= 500) {
request.log.error(error); request.log.error(error);
@@ -34,87 +38,99 @@ export function buildApp(dependencies) {
return reply.code(statusCode).send({ return reply.code(statusCode).send({
error: { error: {
code: error.code ?? 'INTERNAL_SERVER_ERROR', code: error.code ?? "INTERNAL_SERVER_ERROR",
message, message,
}, },
}); });
}); });
app.get('/health', async () => ({ data: { status: 'ok' } })); app.get("/health", async () => ({ data: { status: "ok" } }));
app.register(cookie); app.register(cookie);
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } }); app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
app.register(registerAuthRoutes, { app.register(registerAuthRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/auth', prefix: "/api/v1/auth",
}); });
app.register(registerAdminProductRoutes, { app.register(registerAdminProductRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminInventoryRoutes, { app.register(registerAdminInventoryRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminLookupRoutes, { app.register(registerAdminLookupRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminImageRoutes, { app.register(registerAdminImageRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminListingRoutes, { app.register(registerAdminListingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminSaleRoutes, { app.register(registerAdminSaleRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminAcquisitionRoutes, { app.register(registerAdminAcquisitionRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminDashboardRoutes, { app.register(registerAdminDashboardRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminPricingRoutes, { app.register(registerAdminPricingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
}); });
app.register(registerAdminProviderMappingRoutes, { app.register(registerAdminProviderMappingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1/admin', prefix: "/api/v1/admin",
});
app.register(registerAdminExportRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerAdminSettingsRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerAdminSavedViewRoutes, {
...dependencies,
prefix: "/api/v1/admin",
}); });
app.register(registerPublicSearchRoutes, { app.register(registerPublicSearchRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicProductRoutes, { app.register(registerPublicProductRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicListingRoutes, { app.register(registerPublicListingRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicInventoryRoutes, { app.register(registerPublicInventoryRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicImageRoutes, { app.register(registerPublicImageRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicSellerRoutes, { app.register(registerPublicSellerRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
app.register(registerPublicLookupRoutes, { app.register(registerPublicLookupRoutes, {
...dependencies, ...dependencies,
prefix: '/api/v1', prefix: "/api/v1",
}); });
return app; return app;
} }
+153 -76
View File
@@ -1,85 +1,140 @@
import dotenv from 'dotenv'; import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
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 { buildApp } from "./build-app.js";
import { createDatabase } from '../infrastructure/database/client.js'; import { createDatabase } from "../infrastructure/database/client.js";
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js'; import { resolveProjectImageStorageRoot } from "../infrastructure/images/local-image-storage.js";
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js'; import { createPasswordResetNotifier } from "../infrastructure/email/password-reset-notifier.js";
import { createRedisConnection } from '../infrastructure/redis/client.js'; import { createRedisConnection } from "../infrastructure/redis/client.js";
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js'; import { createAuthenticationService } from "../modules/auth/services/authentication-service.js";
import { createMfaService } from '../modules/auth/services/mfa-service.js'; import { createMfaService } from "../modules/auth/services/mfa-service.js";
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js'; import { createSecretProtector } from "../modules/auth/domain/secret-protection.js";
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js'; import { createMfaMethodRepository } from "../modules/auth/repositories/mfa-method-repository.js";
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js'; import { createSecurityAuditRepository } from "../modules/auth/repositories/security-audit-repository.js";
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js'; import { createSessionRepository } from "../modules/auth/repositories/session-repository.js";
import { createUserRepository } from '../modules/auth/repositories/user-repository.js'; import { createUserRepository } from "../modules/auth/repositories/user-repository.js";
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js'; import { createPasswordResetRepository } from "../modules/auth/repositories/password-reset-repository.js";
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js'; import { createRedisLoginRateLimiter } from "../modules/auth/services/login-rate-limiter.js";
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js'; import { createPasswordRecoveryService } from "../modules/auth/services/password-recovery-service.js";
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js'; import { createInventoryItemRepository } from "../modules/inventory/repositories/inventory-item-repository.js";
import { createCatalogLookupRepository } from '../modules/inventory/repositories/catalog-lookup-repository.js'; import { createCatalogLookupRepository } from "../modules/inventory/repositories/catalog-lookup-repository.js";
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js'; import { createInventoryItemService } from "../modules/inventory/services/inventory-item-service.js";
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js'; import { createPublicInventoryService } from "../modules/inventory/services/public-inventory-service.js";
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js'; import { createPublicImageService } from "../modules/inventory/services/public-image-service.js";
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageRepository } from "../modules/inventory/repositories/inventory-image-repository.js";
import { createImageDerivativeJobRepository } from '../modules/inventory/repositories/image-derivative-job-repository.js'; import { createImageDerivativeJobRepository } from "../modules/inventory/repositories/image-derivative-job-repository.js";
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js'; import { createInventoryImageService } from "../modules/inventory/services/inventory-image-service.js";
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js'; import { createLocalImageStorage } from "../infrastructure/images/local-image-storage.js";
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js'; import { createConditionRepository } from "../modules/inventory/repositories/condition-repository.js";
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js'; import { createListingRepository } from "../modules/listings/repositories/listing-repository.js";
import { createListingService } from '../modules/listings/services/listing-service.js'; import { createListingService } from "../modules/listings/services/listing-service.js";
import { createPublicListingService } from '../modules/listings/services/public-listing-service.js'; import { createPublicListingService } from "../modules/listings/services/public-listing-service.js";
import { createProductRepository } from '../modules/products/repositories/product-repository.js'; import { createProductRepository } from "../modules/products/repositories/product-repository.js";
import { createProductService } from '../modules/products/services/product-service.js'; import { createProductService } from "../modules/products/services/product-service.js";
import { createPublicProductService } from '../modules/products/services/public-product-service.js'; import { createPublicProductService } from "../modules/products/services/public-product-service.js";
import { createSearchService } from '../modules/search/services/search-service.js'; import { createSearchService } from "../modules/search/services/search-service.js";
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js'; import { createSellerRepository } from "../modules/sellers/repositories/seller-repository.js";
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js'; import { createPublicSellerService } from "../modules/sellers/services/public-seller-service.js";
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js'; import { createSaleRepository } from "../modules/sales/repositories/sale-repository.js";
import { createSaleService } from '../modules/sales/services/sale-service.js'; import { createSaleService } from "../modules/sales/services/sale-service.js";
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionRepository } from "../modules/acquisitions/repositories/acquisition-repository.js";
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js'; import { createAcquisitionService } from "../modules/acquisitions/services/acquisition-service.js";
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js'; import { createDashboardService } from "../modules/dashboard/services/dashboard-service.js";
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js'; import { createPricingRepository } from "../modules/pricing/repositories/pricing-repository.js";
import { createPricingService } from '../modules/pricing/services/pricing-service.js'; import { createPricingService } from "../modules/pricing/services/pricing-service.js";
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js'; import { createPricingRefreshJobRepository } from "../modules/pricing/repositories/pricing-refresh-job-repository.js";
import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js'; import { createProviderMappingRepository } from "../modules/pricing/repositories/provider-mapping-repository.js";
import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js'; import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
import { createPricingProviderRegistry } from '../modules/pricing/providers/pricing-provider.js'; import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from '../modules/pricing/providers/fixture-pricing-provider.js'; import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
import {
assertProductionPricingProviderConfig,
createHttpPricingProvider,
} from "../modules/pricing/providers/http-pricing-provider.js";
import { createAdminExportService } from "../modules/exports/services/admin-export-service.js";
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
import { createSavedViewRepository } from "../modules/search/repositories/saved-view-repository.js";
import { createSavedViewService } from "../modules/search/services/saved-view-service.js";
function parsePort(value) { function parsePort(value) {
const port = Number.parseInt(value ?? '3000', 10); const port = Number.parseInt(value ?? "3000", 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) { if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('API_PORT must be an integer between 1 and 65535.'); throw new Error("API_PORT must be an integer between 1 and 65535.");
} }
return port; return port;
} }
async function startServer() { async function startServer() {
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url); if (process.env.NODE_ENV === "production") {
assertProductionPricingProviderConfig({
fixtureEnabled: process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true",
endpoint: process.env.PRICING_HTTP_PROVIDER_ENDPOINT,
});
}
const imageStorageRoot = resolveProjectImageStorageRoot(
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
import.meta.url,
);
const database = createDatabase(); const database = createDatabase();
const redis = await createRedisConnection(); const redis = await createRedisConnection();
const productRepository = createProductRepository(database.db); const productRepository = createProductRepository(database.db);
const inventoryItemRepository = createInventoryItemRepository(database.db); const inventoryItemRepository = createInventoryItemRepository(database.db);
const catalogLookupRepository = createCatalogLookupRepository(database.db); const catalogLookupRepository = createCatalogLookupRepository(database.db);
const inventoryImageRepository = createInventoryImageRepository(database.db); const inventoryImageRepository = createInventoryImageRepository(database.db);
const imageDerivativeJobRepository = createImageDerivativeJobRepository(database.db); const imageDerivativeJobRepository = createImageDerivativeJobRepository(
database.db,
);
const listingRepository = createListingRepository(database.db); const listingRepository = createListingRepository(database.db);
const sellerRepository = createSellerRepository(database.db); const sellerRepository = createSellerRepository(database.db);
const conditionRepository = createConditionRepository(database.db); const conditionRepository = createConditionRepository(database.db);
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
const acquisitionRepository = createAcquisitionRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db); const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(
const providerMappingRepository = createProviderMappingRepository(database.db); database.db,
const pricingProviders = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true' );
? createPricingProviderRegistry([createFixturePricingProvider()]) const providerMappingRepository = createProviderMappingRepository(
: createPricingProviderRegistry(); database.db,
);
const sellerSettingsService = createSellerSettingsService({
sellerSettingsRepository: createSellerSettingsRepository(database.db),
});
const savedViewService = createSavedViewService({
savedViewRepository: createSavedViewRepository(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 pricingService = createPricingService({
pricingRepository,
pricingRefreshJobRepository,
providerMappingRepository,
pricingProviderRegistry: pricingProviders,
});
const inventoryItemService = createInventoryItemService({
conditionRepository,
inventoryItemRepository,
pricingService,
productRepository,
});
const userRepository = createUserRepository(database.db); const userRepository = createUserRepository(database.db);
const sessionRepository = createSessionRepository(database.db); const sessionRepository = createSessionRepository(database.db);
const passwordResetRepository = createPasswordResetRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db);
@@ -105,11 +160,15 @@ async function startServer() {
const app = buildApp({ const app = buildApp({
authenticationService, authenticationService,
authenticateAdmin: (request) => authenticateAdmin: (request) =>
authenticationService.authenticate(request.cookies.pokemon_inventory_session), authenticationService.authenticate(
request.cookies.pokemon_inventory_session,
),
authorizeUnsafeRequest: (sessionToken, csrfToken) => authorizeUnsafeRequest: (sessionToken, csrfToken) =>
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken), authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === "production",
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }), loginRateLimiter: createRedisLoginRateLimiter({
redisClient: redis.client,
}),
mfaService, mfaService,
passwordRecoveryService: createPasswordRecoveryService({ passwordRecoveryService: createPasswordRecoveryService({
passwordResetNotifier, passwordResetNotifier,
@@ -118,52 +177,70 @@ async function startServer() {
userRepository, userRepository,
}), }),
passwordResetRateLimiter: createRedisLoginRateLimiter({ passwordResetRateLimiter: createRedisLoginRateLimiter({
keyPrefix: 'pokemon-inventory:auth:password-reset:', keyPrefix: "pokemon-inventory:auth:password-reset:",
redisClient: redis.client, redisClient: redis.client,
}), }),
inventoryItemService: createInventoryItemService({ inventoryItemService,
conditionRepository, adminExportService: createAdminExportService({
inventoryItemRepository, inventoryExportJobRepository: createInventoryExportJobRepository(database.db),
productRepository, inventoryItemService,
}), }),
sellerSettingsService,
savedViewService,
catalogLookupRepository, catalogLookupRepository,
publicImageService, publicImageService,
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }), publicInventoryService: createPublicInventoryService({
inventoryItemRepository,
publicImageService,
}),
inventoryImageService: createInventoryImageService({ inventoryImageService: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: createLocalImageStorage(imageStorageRoot), imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository, imageDerivativeJobRepository,
inventoryImageRepository, inventoryImageRepository,
sellerSettingsRepository,
}),
listingService: createListingService({
listingRepository,
inventoryImageRepository,
}), }),
listingService: createListingService({ listingRepository, inventoryImageRepository }),
saleService: createSaleService({ saleRepository }), saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
dashboardService: createDashboardService({ dashboardService: createDashboardService({
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }), inventoryItemService,
listingService: createListingService({ listingRepository, inventoryImageRepository }), listingService: createListingService({
listingRepository,
inventoryImageRepository,
}),
saleService: createSaleService({ saleRepository }), saleService: createSaleService({ saleRepository }),
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
pricingRepository, pricingRepository,
pricingRefreshJobRepository, pricingRefreshJobRepository,
}), }),
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }), pricingService,
providerMappingService: createProviderMappingService({ providerMappingRepository }), providerMappingService: createProviderMappingService({
publicListingService: createPublicListingService({ listingRepository, publicImageService }), providerMappingRepository,
}),
publicListingService: createPublicListingService({
listingRepository,
publicImageService,
}),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
publicProductService: createPublicProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }),
searchService: createSearchService({ listingRepository }), searchService: createSearchService({ listingRepository }),
publicSellerService: createPublicSellerService({ sellerRepository }), publicSellerService: createPublicSellerService({ sellerRepository }),
}); });
app.addHook('onClose', async () => { app.addHook("onClose", async () => {
await database.close(); await database.close();
await redis.close(); await redis.close();
}); });
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) }); await app.listen({ host: "0.0.0.0", port: parsePort(process.env.API_PORT) });
} }
startServer().catch((error) => { startServer().catch((error) => {
console.error(error); console.error(error);
process.exitCode = 1; process.exitCode = 1;
}); });
+369 -264
View File
@@ -10,100 +10,143 @@ import {
timestamp, timestamp,
uniqueIndex, uniqueIndex,
uuid, uuid,
} from 'drizzle-orm/pg-core'; } from "drizzle-orm/pg-core";
import { sql } from 'drizzle-orm'; import { sql } from "drizzle-orm";
export const userRole = pgEnum('user_role', ['ADMIN']); export const userRole = pgEnum("user_role", ["ADMIN"]);
export const inventoryStatus = pgEnum('inventory_status', [ export const inventoryStatus = pgEnum("inventory_status", [
'ACTIVE', "ACTIVE",
'SOLD', "SOLD",
'ARCHIVED', "ARCHIVED",
]); ]);
export const listingStatus = pgEnum('listing_status', [ export const listingStatus = pgEnum("listing_status", [
'DRAFT', "DRAFT",
'AVAILABLE', "AVAILABLE",
'PENDING', "PENDING",
'CLOSED', "CLOSED",
'ARCHIVED', "ARCHIVED",
]); ]);
const timestamps = { const timestamps = {
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }) updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}; };
export const users = pgTable( export const users = pgTable(
'users', "users",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
email: text('email').notNull(), email: text("email").notNull(),
passwordHash: text('password_hash').notNull(), passwordHash: text("password_hash").notNull(),
role: userRole('role').default('ADMIN').notNull(), role: userRole("role").default("ADMIN").notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex('users_email_unique').on(table.email)], (table) => [uniqueIndex("users_email_unique").on(table.email)],
); );
export const sellers = pgTable( export const sellers = pgTable(
'sellers', "sellers",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
displayName: text('display_name').notNull(), displayName: text("display_name").notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)], (table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
);
export const sellerSettings = pgTable("seller_settings", {
sellerId: uuid("seller_id")
.primaryKey()
.references(() => sellers.id, { onDelete: "cascade" }),
settings: jsonb("settings").default({}).notNull(),
...timestamps,
});
export const inventoryExportJobs = pgTable(
"inventory_export_jobs",
{
id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid("seller_id")
.notNull()
.references(() => sellers.id, { onDelete: "cascade" }),
format: text("format").notNull(),
status: text("status").default("PENDING").notNull(),
output: jsonb("output"),
error: text("error"),
...timestamps,
},
(table) => [
index("inventory_export_jobs_status_created_at_index").on(
table.status,
table.createdAt,
),
],
);
export const savedViews = pgTable(
"saved_views",
{
id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid("seller_id")
.notNull()
.references(() => sellers.id, { onDelete: "cascade" }),
name: text("name").notNull(),
filters: jsonb("filters").default({}).notNull(),
...timestamps,
},
(table) => [uniqueIndex("saved_views_seller_name_unique").on(table.sellerId, table.name)],
); );
export const sessions = pgTable( export const sessions = pgTable(
'sessions', "sessions",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
sessionTokenHash: text('session_token_hash').notNull(), sessionTokenHash: text("session_token_hash").notNull(),
csrfTokenHash: text('csrf_token_hash').notNull(), csrfTokenHash: text("csrf_token_hash").notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }) lastActivityAt: timestamp("last_activity_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
ipAddress: text('ip_address'), ipAddress: text("ip_address"),
userAgent: text('user_agent'), userAgent: text("user_agent"),
revokedAt: timestamp('revoked_at', { withTimezone: true }), revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash), uniqueIndex("sessions_token_hash_unique").on(table.sessionTokenHash),
index('sessions_user_id_index').on(table.userId), index("sessions_user_id_index").on(table.userId),
index('sessions_expires_at_index').on(table.expiresAt), index("sessions_expires_at_index").on(table.expiresAt),
], ],
); );
export const securityAuditEvents = pgTable( export const securityAuditEvents = pgTable(
'security_audit_events', "security_audit_events",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id), userId: uuid("user_id").references(() => users.id),
event: text('event').notNull(), event: text("event").notNull(),
ipAddress: text('ip_address'), ipAddress: text("ip_address"),
userAgent: text('user_agent'), userAgent: text("user_agent"),
metadata: jsonb('metadata').default({}).notNull(), metadata: jsonb("metadata").default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
index('security_audit_events_user_id_index').on(table.userId), index("security_audit_events_user_id_index").on(table.userId),
index('security_audit_events_event_created_at_index').on( index("security_audit_events_event_created_at_index").on(
table.event, table.event,
table.createdAt, table.createdAt,
), ),
@@ -111,89 +154,89 @@ export const securityAuditEvents = pgTable(
); );
export const passwordResetTokens = pgTable( export const passwordResetTokens = pgTable(
'password_reset_tokens', "password_reset_tokens",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
tokenHash: text('token_hash').notNull(), tokenHash: text("token_hash").notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }), usedAt: timestamp("used_at", { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash), uniqueIndex("password_reset_tokens_token_hash_unique").on(table.tokenHash),
index('password_reset_tokens_user_id_index').on(table.userId), index("password_reset_tokens_user_id_index").on(table.userId),
], ],
); );
export const mfaMethods = pgTable( export const mfaMethods = pgTable(
'mfa_methods', "mfa_methods",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
userId: uuid('user_id') userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
type: text('type').notNull(), type: text("type").notNull(),
secretCiphertext: text('secret_ciphertext').notNull(), secretCiphertext: text("secret_ciphertext").notNull(),
name: text('name').notNull(), name: text("name").notNull(),
isEnabled: boolean('is_enabled').default(false).notNull(), isEnabled: boolean("is_enabled").default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }), lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
disabledAt: timestamp('disabled_at', { withTimezone: true }), disabledAt: timestamp("disabled_at", { withTimezone: true }),
}, },
(table) => [index('mfa_methods_user_id_index').on(table.userId)], (table) => [index("mfa_methods_user_id_index").on(table.userId)],
); );
export const categories = pgTable( export const categories = pgTable(
'categories', "categories",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
name: text('name').notNull(), name: text("name").notNull(),
slug: text('slug').notNull(), slug: text("slug").notNull(),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('categories_name_unique').on(table.name), uniqueIndex("categories_name_unique").on(table.name),
uniqueIndex('categories_slug_unique').on(table.slug), uniqueIndex("categories_slug_unique").on(table.slug),
], ],
); );
export const conditionProfiles = pgTable('condition_profiles', { export const conditionProfiles = pgTable("condition_profiles", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
categoryId: uuid('category_id') categoryId: uuid("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
name: text('name').notNull(), name: text("name").notNull(),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}); });
export const conditions = pgTable( export const conditions = pgTable(
'conditions', "conditions",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
conditionProfileId: uuid('condition_profile_id') conditionProfileId: uuid("condition_profile_id")
.notNull() .notNull()
.references(() => conditionProfiles.id), .references(() => conditionProfiles.id),
name: text('name').notNull(), name: text("name").notNull(),
code: text('code').notNull(), code: text("code").notNull(),
qualityRank: integer('quality_rank').notNull(), qualityRank: integer("quality_rank").notNull(),
description: text('description'), description: text("description"),
active: boolean('active').default(true).notNull(), active: boolean("active").default(true).notNull(),
sort: integer('sort').default(0).notNull(), sort: integer("sort").default(0).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('conditions_profile_code_unique').on( uniqueIndex("conditions_profile_code_unique").on(
table.conditionProfileId, table.conditionProfileId,
table.code, table.code,
), ),
@@ -201,229 +244,291 @@ export const conditions = pgTable(
); );
export const products = pgTable( export const products = pgTable(
'products', "products",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid('seller_id') sellerId: uuid("seller_id")
.notNull() .notNull()
.references(() => sellers.id), .references(() => sellers.id),
categoryId: uuid('category_id') categoryId: uuid("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
name: text('name').notNull(), name: text("name").notNull(),
slug: text('slug').notNull(), slug: text("slug").notNull(),
description: text('description'), description: text("description"),
brand: text('brand'), brand: text("brand"),
setName: text('set_name'), setName: text("set_name"),
cardNumber: text('card_number'), cardNumber: text("card_number"),
eventName: text('event_name'), eventName: text("event_name"),
releaseYear: integer('release_year'), releaseYear: integer("release_year"),
tags: text("tags").array().default([]).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug), uniqueIndex("products_seller_slug_unique").on(table.sellerId, table.slug),
check( check(
'products_release_year_valid', "products_release_year_valid",
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`, sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
), ),
], ],
); );
export const inventoryItems = pgTable('inventory_items', { export const inventoryItems = pgTable("inventory_items", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
productId: uuid('product_id') productId: uuid("product_id")
.notNull() .notNull()
.references(() => products.id), .references(() => products.id),
conditionId: uuid('condition_id') conditionId: uuid("condition_id")
.notNull() .notNull()
.references(() => conditions.id), .references(() => conditions.id),
status: inventoryStatus('status').default('ACTIVE').notNull(), status: inventoryStatus("status").default("ACTIVE").notNull(),
notes: text('notes'), notes: text("notes"),
...timestamps, ...timestamps,
}); });
export const acquisitions = pgTable('acquisitions', { export const acquisitions = pgTable("acquisitions", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id') inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
method: text('method').notNull(), method: text("method").notNull(),
costMinor: integer('cost_minor').notNull(), costMinor: integer("cost_minor").notNull(),
currency: text('currency').notNull(), currency: text("currency").notNull(),
date: text('date'), date: text("date"),
source: text('source'), source: text("source"),
location: text('location'), location: text("location"),
shippingMinor: integer('shipping_minor').default(0).notNull(), shippingMinor: integer("shipping_minor").default(0).notNull(),
taxMinor: integer('tax_minor').default(0).notNull(), taxMinor: integer("tax_minor").default(0).notNull(),
otherFeesMinor: integer('other_fees_minor').default(0).notNull(), otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(), estimatedTradeValueMinor: integer("estimated_trade_value_minor")
notes: text('notes'), .default(0)
.notNull(),
notes: text("notes"),
...timestamps, ...timestamps,
}); });
export const pricingObservations = pgTable('pricing_observations', { export const pricingObservations = pgTable(
id: uuid('id').defaultRandom().primaryKey(), "pricing_observations",
productId: uuid('product_id').notNull().references(() => products.id),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
providerId: text('provider_id').notNull(),
priceMinor: integer('price_minor').notNull(),
currency: text('currency').notNull(),
sourceIdentifier: text('source_identifier').notNull(),
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
sourceUrl: text('source_url'),
qualityScore: integer('quality_score'),
isOutlier: boolean('is_outlier').default(false).notNull(),
outlierReason: text('outlier_reason'),
observedAt: timestamp('observed_at', { withTimezone: true }).defaultNow().notNull(),
...timestamps,
}, (table) => [index('pricing_observations_inventory_item_id_index').on(table.inventoryItemId)]);
export const marketValuations = pgTable('market_valuations', {
id: uuid('id').defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
valueMinor: integer('value_minor').notNull(),
currency: text('currency').notNull(),
strategy: text('strategy').notNull(),
confidence: text('confidence').notNull(),
sourceCount: integer('source_count').notNull(),
calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const marketValueOverrides = pgTable('market_value_overrides', {
id: uuid('id').defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
valueMinor: integer('value_minor').notNull(),
currency: text('currency').notNull(),
reason: text('reason').notNull(),
createdBy: uuid('created_by').notNull().references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const pricingProviderMappings = pgTable('pricing_provider_mappings', {
id: uuid('id').defaultRandom().primaryKey(),
productId: uuid('product_id').notNull().references(() => products.id),
providerId: text('provider_id').notNull(),
mappingType: text('mapping_type').notNull(),
externalIdentifier: text('external_identifier'),
searchQuery: text('search_query'),
sourceUrl: text('source_url'),
status: text('status').default('ACTIVE').notNull(),
metadata: jsonb('metadata').default({}).notNull(),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
...timestamps,
});
export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', {
id: uuid('id').defaultRandom().primaryKey(),
sellerId: uuid('seller_id').notNull().references(() => sellers.id),
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
productId: uuid('product_id').notNull().references(() => products.id),
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
providerId: text('provider_id').notNull(),
status: text('status').default('PENDING').notNull(),
attemptCount: integer('attempt_count').default(0).notNull(),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }),
observations: jsonb('observations').default([]).notNull(),
valuation: jsonb('valuation'),
error: text('error'),
...timestamps,
}, (table) => [index('pricing_refresh_jobs_status_created_at_index').on(table.status, table.createdAt)]);
export const inventoryImages = pgTable(
'inventory_images',
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid('inventory_item_id') productId: uuid("product_id")
.notNull()
.references(() => products.id),
inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
storageKey: text('storage_key').notNull(), providerId: text("provider_id").notNull(),
originalFilename: text('original_filename').notNull(), externalIdentifier: text("external_identifier"),
mimeType: text('mime_type').notNull(), searchQuery: text("search_query"),
fileSize: integer('file_size').notNull(), priceMinor: integer("price_minor").notNull(),
width: integer('width').notNull(), currency: text("currency").notNull(),
height: integer('height').notNull(), sourceIdentifier: text("source_identifier").notNull(),
altText: text('alt_text'), conditionId: uuid("condition_id")
sortOrder: integer('sort_order').default(0).notNull(), .notNull()
isPrimary: boolean('is_primary').default(false).notNull(), .references(() => conditions.id),
processingStatus: text('processing_status').default('PENDING').notNull(), sourceUrl: text("source_url"),
qualityScore: integer("quality_score"),
isOutlier: boolean("is_outlier").default(false).notNull(),
outlierReason: text("outlier_reason"),
observedAt: timestamp("observed_at", { withTimezone: true })
.defaultNow()
.notNull(),
...timestamps, ...timestamps,
}, },
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)], (table) => [
index("pricing_observations_inventory_item_id_index").on(
table.inventoryItemId,
),
],
);
export const marketValuations = pgTable("market_valuations", {
id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
valueMinor: integer("value_minor").notNull(),
currency: text("currency").notNull(),
strategy: text("strategy").notNull(),
confidence: text("confidence").notNull(),
sourceCount: integer("source_count").notNull(),
calculatedAt: timestamp("calculated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const marketValueOverrides = pgTable("market_value_overrides", {
id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
valueMinor: integer("value_minor").notNull(),
currency: text("currency").notNull(),
reason: text("reason").notNull(),
createdBy: uuid("created_by")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const pricingProviderMappings = pgTable("pricing_provider_mappings", {
id: uuid("id").defaultRandom().primaryKey(),
productId: uuid("product_id")
.notNull()
.references(() => products.id),
providerId: text("provider_id").notNull(),
mappingType: text("mapping_type").notNull(),
externalIdentifier: text("external_identifier"),
searchQuery: text("search_query"),
sourceUrl: text("source_url"),
status: text("status").default("ACTIVE").notNull(),
metadata: jsonb("metadata").default({}).notNull(),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
...timestamps,
});
export const pricingRefreshJobs = pgTable(
"pricing_refresh_jobs",
{
id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid("seller_id")
.notNull()
.references(() => sellers.id),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
productId: uuid("product_id")
.notNull()
.references(() => products.id),
conditionId: uuid("condition_id")
.notNull()
.references(() => conditions.id),
providerId: text("provider_id").notNull(),
status: text("status").default("PENDING").notNull(),
attemptCount: integer("attempt_count").default(0).notNull(),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
observations: jsonb("observations").default([]).notNull(),
valuation: jsonb("valuation"),
error: text("error"),
...timestamps,
},
(table) => [
index("pricing_refresh_jobs_status_created_at_index").on(
table.status,
table.createdAt,
),
],
);
export const inventoryImages = pgTable(
"inventory_images",
{
id: uuid("id").defaultRandom().primaryKey(),
inventoryItemId: uuid("inventory_item_id")
.notNull()
.references(() => inventoryItems.id),
storageKey: text("storage_key").notNull(),
originalFilename: text("original_filename").notNull(),
mimeType: text("mime_type").notNull(),
fileSize: integer("file_size").notNull(),
width: integer("width").notNull(),
height: integer("height").notNull(),
altText: text("alt_text"),
sortOrder: integer("sort_order").default(0).notNull(),
isPrimary: boolean("is_primary").default(false).notNull(),
processingStatus: text("processing_status").default("PENDING").notNull(),
...timestamps,
},
(table) => [
index("inventory_images_inventory_item_id_index").on(table.inventoryItemId),
],
); );
export const imageDerivativeJobs = pgTable( export const imageDerivativeJobs = pgTable(
'image_derivative_jobs', "image_derivative_jobs",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
inventoryImageId: uuid('inventory_image_id') inventoryImageId: uuid("inventory_image_id")
.notNull() .notNull()
.references(() => inventoryImages.id), .references(() => inventoryImages.id),
sourceKey: text('source_key').notNull(), sourceKey: text("source_key").notNull(),
status: text('status').default('PENDING').notNull(), status: text("status").default("PENDING").notNull(),
derivatives: jsonb('derivatives').default([]).notNull(), derivatives: jsonb("derivatives").default([]).notNull(),
error: text('error'), error: text("error"),
...timestamps, ...timestamps,
}, },
(table) => [index('image_derivative_jobs_image_id_index').on(table.inventoryImageId)], (table) => [
index("image_derivative_jobs_image_id_index").on(table.inventoryImageId),
],
); );
export const listings = pgTable('listings', { export const listings = pgTable(
id: uuid('id').defaultRandom().primaryKey(), "listings",
inventoryItemId: uuid('inventory_item_id') {
.notNull() id: uuid("id").defaultRandom().primaryKey(),
.references(() => inventoryItems.id), inventoryItemId: uuid("inventory_item_id")
productName: text('product_name').notNull(), .notNull()
categoryId: uuid('category_id').notNull(), .references(() => inventoryItems.id),
conditionId: uuid('condition_id') productName: text("product_name").notNull(),
.notNull() categoryId: uuid("category_id").notNull(),
.references(() => conditions.id), conditionId: uuid("condition_id")
askingPriceMinor: integer('asking_price_minor').notNull(), .notNull()
status: listingStatus('status').default('DRAFT').notNull(), .references(() => conditions.id),
notes: text('notes'), askingPriceMinor: integer("asking_price_minor").notNull(),
...timestamps, status: listingStatus("status").default("DRAFT").notNull(),
}, (table) => [ notes: text("notes"),
index('listings_inventory_item_id_index').on(table.inventoryItemId), ...timestamps,
index('listings_status_index').on(table.status), },
]); (table) => [
index("listings_inventory_item_id_index").on(table.inventoryItemId),
index("listings_status_index").on(table.status),
],
);
export const sales = pgTable('sales', { export const sales = pgTable("sales", {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid('seller_id') sellerId: uuid("seller_id")
.notNull() .notNull()
.references(() => sellers.id), .references(() => sellers.id),
status: text('status').default('COMPLETED').notNull(), status: text("status").default("COMPLETED").notNull(),
saleDate: text('sale_date').notNull(), saleDate: text("sale_date").notNull(),
saleLocation: text('sale_location'), saleLocation: text("sale_location"),
soldPriceMinor: integer('sold_price_minor').notNull(), soldPriceMinor: integer("sold_price_minor").notNull(),
currency: text('currency').notNull(), currency: text("currency").notNull(),
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(), platformFeesMinor: integer("platform_fees_minor").default(0).notNull(),
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(), paymentProcessingFeesMinor: integer("payment_processing_fees_minor")
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(), .default(0)
taxesMinor: integer('taxes_minor').default(0).notNull(), .notNull(),
otherFeesMinor: integer('other_fees_minor').default(0).notNull(), shippingCostMinor: integer("shipping_cost_minor").default(0).notNull(),
buyerName: text('buyer_name'), taxesMinor: integer("taxes_minor").default(0).notNull(),
buyerContact: text('buyer_contact'), otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
notes: text('notes'), buyerName: text("buyer_name"),
voidReason: text('void_reason'), buyerContact: text("buyer_contact"),
voidedAt: timestamp('voided_at', { withTimezone: true }), notes: text("notes"),
voidReason: text("void_reason"),
voidedAt: timestamp("voided_at", { withTimezone: true }),
...timestamps, ...timestamps,
}); });
export const saleItems = pgTable( export const saleItems = pgTable(
'sale_items', "sale_items",
{ {
id: uuid('id').defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
saleId: uuid('sale_id') saleId: uuid("sale_id")
.notNull() .notNull()
.references(() => sales.id), .references(() => sales.id),
inventoryItemId: uuid('inventory_item_id') inventoryItemId: uuid("inventory_item_id")
.notNull() .notNull()
.references(() => inventoryItems.id), .references(() => inventoryItems.id),
soldPriceMinor: integer('sold_price_minor').notNull(), soldPriceMinor: integer("sold_price_minor").notNull(),
costBasisMinor: integer('cost_basis_minor').notNull(), costBasisMinor: integer("cost_basis_minor").notNull(),
}, },
(table) => [ (table) => [
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId), uniqueIndex("sale_items_inventory_item_id_unique").on(
index('sale_items_sale_id_index').on(table.saleId), table.inventoryItemId,
),
index("sale_items_sale_id_index").on(table.saleId),
], ],
); );
@@ -0,0 +1,18 @@
import sharp from 'sharp';
const options = { failOn: 'warning', limitInputPixels: 100_000_000 };
const mimeTypes = { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
export function createSharpImageInspector() {
return {
async readMetadata(data) {
const metadata = await sharp(data, options).metadata();
return { width: metadata.width, height: metadata.height, mimeType: mimeTypes[metadata.format] };
},
async validateDecoding(data) {
// Decode only after the service has checked metadata limits. The tiny
// output is discarded; originals and derivatives retain their own flow.
await sharp(data, options).resize(1, 1).raw().toBuffer();
},
};
}
@@ -1,6 +1,10 @@
import { and, desc, eq } from 'drizzle-orm'; import { and, desc, eq } from "drizzle-orm";
import { acquisitions, inventoryItems, products } from '../../../infrastructure/database/schema.js'; import {
acquisitions,
inventoryItems,
products,
} from "../../../infrastructure/database/schema.js";
export function createAcquisitionRepository(db) { export function createAcquisitionRepository(db) {
return { return {
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
.select({ id: inventoryItems.id }) .select({ id: inventoryItems.id })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId))); .where(
and(
eq(inventoryItems.id, acquisition.inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: 'NOT_FOUND' }; if (!ownedItem) return { status: "NOT_FOUND" };
const [created] = await transaction.insert(acquisitions).values(acquisition).returning(); const [created] = await transaction
return { status: 'CREATED', acquisition: created }; .insert(acquisitions)
.values(acquisition)
.returning();
return { status: "CREATED", acquisition: created };
}); });
}, },
@@ -22,11 +34,14 @@ export function createAcquisitionRepository(db) {
return db return db
.select({ acquisition: acquisitions }) .select({ acquisition: acquisitions })
.from(acquisitions) .from(acquisitions)
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, acquisitions.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(acquisitions.createdAt)) .orderBy(desc(acquisitions.createdAt))
.then((rows) => rows.map(({ acquisition }) => acquisition)); .then((rows) => rows.map(({ acquisition }) => acquisition));
}, },
}; };
} }
@@ -1,42 +1,64 @@
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition'; import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition'; import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminAcquisitionRoutes(app, options) { export async function registerAdminAcquisitionRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
acquisitionService, acquisitionService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/acquisitions', async (request) => ({ app.get("/acquisitions", async (request) => ({
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto), data: (
await acquisitionService.list({ sellerId: request.admin.sellerId })
).map(toAdminAcquisitionDto),
})); }));
app.post('/acquisitions', async (request, reply) => { app.post("/acquisitions", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createAcquisitionSchema.safeParse(request.body); const result = createAcquisitionSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId }); const acquisition = await acquisitionService.create({
...result.data,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) }); return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
}); });
} }
@@ -1,13 +1,23 @@
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js'; import {
import { createApplicationError } from '../../../shared/errors/application-error.js'; createAcquisition,
calculateAcquisitionCostBasis,
} from "../domain/acquisition.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createAcquisitionService({ acquisitionRepository }) { export function createAcquisitionService({ acquisitionRepository }) {
return { return {
async create({ sellerId, ...input }) { async create({ sellerId, ...input }) {
const acquisition = createAcquisition(input); const acquisition = createAcquisition(input);
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId }); const result = await acquisitionRepository.createForSeller({
if (result.status === 'NOT_FOUND') { acquisition,
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); sellerId,
});
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
} }
return { return {
@@ -1,20 +1,33 @@
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard'; import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export async function registerAdminDashboardRoutes(app, options) { export async function registerAdminDashboardRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
dashboardService, dashboardService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/dashboard', async (request) => ({ app.get("/dashboard", async (request) => ({
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })), data: toAdminDashboardDto(
await dashboardService.get({ sellerId: request.admin.sellerId }),
),
})); }));
} }
@@ -1,7 +1,23 @@
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) { export function createDashboardService({
productService,
inventoryItemService,
listingService,
saleService,
acquisitionService,
pricingRepository,
pricingRefreshJobRepository,
}) {
return { return {
async get({ sellerId }) { async get({ sellerId }) {
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([ const [
products,
inventory,
listings,
sales,
acquisitions,
valuations,
failedPricingJobs,
] = await Promise.all([
productService.listBySellerId(sellerId), productService.listBySellerId(sellerId),
inventoryItemService.listBySellerId(sellerId), inventoryItemService.listBySellerId(sellerId),
listingService.list({ sellerId }), listingService.list({ sellerId }),
@@ -11,36 +27,51 @@ export function createDashboardService({ productService, inventoryItemService, l
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0, pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
]); ]);
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status)); const activeListings = listings.filter(({ status }) =>
const completedSales = sales.filter(({ status }) => status !== 'VOIDED'); ["AVAILABLE", "PENDING"].includes(status),
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation])); );
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
const valuationByItem = new Map(
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
);
const staleThreshold = 7 * 24 * 60 * 60 * 1000; const staleThreshold = 7 * 24 * 60 * 60 * 1000;
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0); const total = (values, field) =>
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
return { return {
counts: { counts: {
products: products.length, products: products.length,
inventory: inventory.length, inventory: inventory.length,
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length, activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length, .length,
soldInventory: inventory.filter(({ status }) => status === "SOLD")
.length,
availableListings: activeListings.length, availableListings: activeListings.length,
sales: completedSales.length, sales: completedSales.length,
acquisitions: acquisitions.length, acquisitions: acquisitions.length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length, missingImages: listings.filter(({ imageCount }) => imageCount < 1)
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length, .length,
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length, missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
.length,
stalePricing: valuations.filter(
({ calculatedAt }) =>
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
).length,
failedPricingJobs, failedPricingJobs,
}, },
financials: { financials: {
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'), totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'), totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'), totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
totalProfitMinor: total(completedSales, 'profitMinor'), totalProfitMinor: total(completedSales, "profitMinor"),
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'), totalMarketValueMinor: total(
[...valuationByItem.values()],
"valueMinor",
),
}, },
recentSales: completedSales.slice(0, 5), recentSales: completedSales.slice(0, 5),
recentAcquisitions: acquisitions.slice(0, 5), recentAcquisitions: acquisitions.slice(0, 5),
}; };
}, },
}; };
} }
@@ -0,0 +1,51 @@
import { and, asc, eq } from 'drizzle-orm';
import { inventoryExportJobs } from '../../../infrastructure/database/schema.js';
export function createInventoryExportJobRepository(db) {
return {
async create(job) {
const [created] = await db.insert(inventoryExportJobs).values(job).returning();
return created;
},
async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(inventoryExportJobs).where(and(
eq(inventoryExportJobs.id, id),
eq(inventoryExportJobs.sellerId, sellerId),
));
return job ?? null;
},
async claimNext() {
return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(inventoryExportJobs)
.where(eq(inventoryExportJobs.status, 'PENDING'))
.orderBy(asc(inventoryExportJobs.createdAt))
.limit(1).for('update', { skipLocked: true });
if (!job) return null;
const [claimed] = await transaction.update(inventoryExportJobs)
.set({ status: 'PROCESSING', updatedAt: new Date(), error: null })
.where(and(eq(inventoryExportJobs.id, job.id), eq(inventoryExportJobs.status, 'PENDING')))
.returning();
return claimed ?? null;
});
},
async complete(id, output) {
const [completed] = await db.update(inventoryExportJobs)
.set({ status: 'COMPLETED', output, updatedAt: new Date(), error: null })
.where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING')))
.returning();
return completed ?? null;
},
async fail(id, error) {
const [failed] = await db.update(inventoryExportJobs)
.set({ status: 'FAILED', error, updatedAt: new Date() })
.where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING')))
.returning();
return failed ?? null;
},
};
}
@@ -0,0 +1,98 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export async function registerAdminExportRoutes(app, options) {
const {
adminExportService,
authenticateAdmin = async () => {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
},
} = options;
app.addHook('preHandler', async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
}
request.admin = admin;
});
app.get('/exports/inventory.json', async (request, reply) => {
const data = await adminExportService.inventoryJson({
sellerId: request.admin.sellerId,
});
return reply
.type('application/json; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.json"')
.send(JSON.stringify(data));
});
app.get('/exports/inventory.csv', async (request, reply) => {
const data = await adminExportService.inventoryCsv({
sellerId: request.admin.sellerId,
});
return reply
.type('text/csv; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.csv"')
.send(data);
});
app.get('/exports/inventory.xls', async (request, reply) => {
const data = await adminExportService.inventoryExcel({
sellerId: request.admin.sellerId,
});
return reply
.type('application/vnd.ms-excel; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.xls"')
.send(data);
});
app.post('/exports/inventory.json/jobs', async (request, reply) => {
const job = await adminExportService.enqueueInventoryJson({
sellerId: request.admin.sellerId,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
app.get('/exports/jobs/:id', async (request, reply) => {
const job = await adminExportService.getExportJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
if (!job) {
throw createApplicationError('EXPORT_JOB_NOT_FOUND', 'Export job was not found.', 404);
}
return reply.send({
data: {
id: job.id,
format: job.format,
status: job.status,
error: job.error ?? null,
output: job.status === 'COMPLETED' ? job.output : null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
},
});
});
app.get('/exports/jobs/:id/download', async (request, reply) => {
const job = await adminExportService.getExportJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
if (!job) {
throw createApplicationError('EXPORT_JOB_NOT_FOUND', 'Export job was not found.', 404);
}
if (job.status !== 'COMPLETED' || !Array.isArray(job.output)) {
throw createApplicationError('EXPORT_NOT_READY', 'Export is not ready for download.', 409);
}
return reply
.type('application/json; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.json"')
.send(JSON.stringify(job.output));
});
}
@@ -0,0 +1,61 @@
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
export function createAdminExportService({ inventoryItemService, inventoryExportJobRepository }) {
async function inventoryRows(sellerId) {
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
return inventoryItems.map(toAdminInventoryItemDto);
}
return {
async enqueueInventoryJson({ sellerId }) {
return inventoryExportJobRepository.create({ sellerId, format: 'JSON' });
},
async getExportJob({ sellerId, jobId }) {
return inventoryExportJobRepository.findByIdForSeller(jobId, sellerId);
},
async inventoryJson({ sellerId }) {
return inventoryRows(sellerId);
},
async inventoryCsv({ sellerId }) {
const rows = await inventoryRows(sellerId);
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
return [
columns.join(','),
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
].join('\r\n');
},
async inventoryExcel({ sellerId }) {
const rows = await inventoryRows(sellerId);
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
const header = columns.map((column) => excelCell(column, true)).join('');
const body = rows.map((row) => (
`<Row>${columns.map((column) => excelCell(row[column])).join('')}</Row>`
)).join('');
return `<?xml version="1.0"?>\n<?mso-application progid="Excel.Sheet"?>\n<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Styles><Style ss:ID="Header"><Font ss:Bold="1" /></Style></Styles><Worksheet ss:Name="Inventory"><Table><Row>${header}</Row>${body}</Table></Worksheet></Workbook>`;
},
};
}
function excelCell(value, header = false) {
const style = header ? ' ss:StyleID="Header"' : '';
return `<Cell${style}><Data ss:Type="String">${escapeXml(value)}</Data></Cell>`;
}
function escapeXml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function escapeCsvValue(value) {
const text = value == null ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
@@ -0,0 +1,19 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function validateImageMove(direction) {
if (direction !== 'up' && direction !== 'down') {
throw createApplicationError('IMAGE_MOVE_INVALID', 'Image move must be up or down.', 400);
}
}
// Input is the persisted display order. Return a reordered copy; boundary moves
// are no-ops. Persistence assigns contiguous positions after an actual move.
export function moveImageInOrder(images, imageId, direction) {
validateImageMove(direction);
const index = images.findIndex((image) => image.id === imageId);
const neighbor = index + (direction === 'up' ? -1 : 1);
if (index < 0 || neighbor < 0 || neighbor >= images.length) return images;
const reordered = [...images];
[reordered[index], reordered[neighbor]] = [reordered[neighbor], reordered[index]];
return reordered;
}
@@ -42,4 +42,17 @@ export function validateImageSignature(data, mimeType) {
} }
return true; return true;
} }
export function validateImageSortOrder(sortOrder) {
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 2147483647) {
throw createApplicationError('IMAGE_ORDER_INVALID', 'Image order must be an integer between 0 and 2147483647.', 400);
}
}
export function normalizeImageAltText(altText) {
if (altText === null) return null;
if (typeof altText !== 'string' || altText.trim().length > 1000) {
throw createApplicationError('IMAGE_ALT_TEXT_INVALID', 'Alt text must be text up to 1,000 characters or null.', 400);
}
return altText.trim();
}
@@ -31,6 +31,10 @@ export function transitionInventoryStatus(currentStatus, nextStatus) {
return nextStatus; return nextStatus;
} }
export function archiveInventoryItem(status) {
return transitionInventoryStatus(status, INVENTORY_STATUS.ARCHIVED);
}
export function assertInventoryItemEditable(status) { export function assertInventoryItemEditable(status) {
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) { if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
throw new Error('Inventory item is locked against ordinary edits.'); throw new Error('Inventory item is locked against ordinary edits.');
@@ -1,4 +1,6 @@
import { moveImageInOrder } from '../domain/image-order.js';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { import {
imageDerivativeJobs, imageDerivativeJobs,
@@ -23,6 +25,24 @@ export function createInventoryImageRepository(db) {
return createdImage; return createdImage;
}, },
async createForSellerWithLimit(image, sellerId, maxImages) {
return db.transaction(async (tx) => {
const [ownedItem] = await tx.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!ownedItem) return null;
const existing = await tx.select({ id: inventoryImages.id }).from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, image.inventoryItemId));
if (existing.length >= maxImages) {
throw createApplicationError('IMAGE_COUNT_LIMIT_REACHED', 'Maximum images per item reached.', 409);
}
const [createdImage] = await tx.insert(inventoryImages).values(image).returning();
return createdImage;
});
},
async countByInventoryItemId(inventoryItemId) { async countByInventoryItemId(inventoryItemId) {
const rows = await db const rows = await db
.select({ id: inventoryImages.id }) .select({ id: inventoryImages.id })
@@ -53,10 +73,42 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.inventoryItemId, inventoryItemId), eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(products.sellerId, sellerId), eq(products.sellerId, sellerId),
)) ))
.orderBy(inventoryImages.sortOrder) .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.then((rows) => rows.map(({ image }) => image)); .then((rows) => rows.map(({ image }) => image));
}, },
async moveForSeller(imageId, direction, sellerId) {
return db.transaction(async (tx) => {
// Lock the owning physical item first, serializing moves within a gallery.
const [owned] = await tx
.select({ inventoryItemId: inventoryItems.id })
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryImages.id, imageId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!owned) return null;
const images = await tx.select().from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId))
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.for('update');
const target = images.find((image) => image.id === imageId);
if (!target) return null;
const reordered = moveImageInOrder(images, imageId, direction);
if (reordered === images) return target;
const updatedAt = new Date();
for (const [sortOrder, image] of reordered.entries()) {
if (image.sortOrder !== sortOrder) {
await tx.update(inventoryImages).set({ sortOrder, updatedAt })
.where(eq(inventoryImages.id, image.id));
}
}
const [moved] = await tx.select().from(inventoryImages).where(eq(inventoryImages.id, imageId));
return moved;
});
},
async updateOrderForSeller(imageId, sortOrder, sellerId) { async updateOrderForSeller(imageId, sortOrder, sellerId) {
const owned = await this.findByIdForSeller(imageId, sellerId); const owned = await this.findByIdForSeller(imageId, sellerId);
if (!owned) return null; if (!owned) return null;
@@ -135,7 +187,7 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.processingStatus, 'COMPLETED'), eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'), eq(imageDerivativeJobs.status, 'COMPLETED'),
)) ))
.orderBy(inventoryImages.sortOrder); .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
}, },
async findPublicById(imageId) { async findPublicById(imageId) {
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; import { and, asc, count, desc, eq, exists, inArray, sql } from 'drizzle-orm';
import { import {
inventoryItems, inventoryItems,
@@ -35,6 +35,46 @@ export function createInventoryItemRepository(db) {
.then((rows) => rows.map(({ item }) => item)); .then((rows) => rows.map(({ item }) => item));
}, },
async archiveByIdsForSeller(inventoryItemIds, sellerId) {
if (inventoryItemIds.length === 0) return [];
return db
.update(inventoryItems)
.set({ status: 'ARCHIVED', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, inventoryItemIds),
eq(inventoryItems.status, 'ACTIVE'),
exists(
db
.select({ one: sql`1` })
.from(products)
.where(and(
eq(products.id, inventoryItems.productId),
eq(products.sellerId, sellerId),
)),
),
))
.returning();
},
async updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags) {
const rows = await db
.select({ productId: inventoryItems.productId })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(inArray(inventoryItems.id, inventoryItemIds), eq(products.sellerId, sellerId)));
const productIds = [...new Set(rows.map(({ productId }) => productId))];
const updated = [];
for (const productId of productIds) {
const [product] = await db.select().from(products).where(eq(products.id, productId));
const tags = [...new Set([...(product.tags ?? []), ...addTags.map((tag) => tag.toLowerCase())])]
.filter((tag) => !removeTags.map((value) => value.toLowerCase()).includes(tag));
const [result] = await db.update(products).set({ tags, updatedAt: new Date() }).where(eq(products.id, productId)).returning();
updated.push(result);
}
return updated;
},
async searchPublic({ page = 1, limit = 20 } = {}) { async searchPublic({ page = 1, limit = 20 } = {}) {
const conditions = [ const conditions = [
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -1,5 +1,5 @@
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image'; import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image'; import { createInventoryImageSchema, uploadInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
const data = await part.toBuffer(); const data = await part.toBuffer();
const fields = part.fields; const fields = part.fields;
const originalFilename = part.filename; const originalFilename = part.filename;
const result = createInventoryImageSchema.safeParse({ const result = uploadInventoryImageSchema.safeParse({
storageKey: `inventory/${request.params.id}/${randomUUID()}`, storageKey: `inventory/${request.params.id}/${randomUUID()}`,
originalFilename, originalFilename,
mimeType: part.mimetype, mimeType: part.mimetype,
fileSize: data.length, fileSize: data.length,
width: Number(fields.width?.value),
height: Number(fields.height?.value),
altText: fields.altText?.value, altText: fields.altText?.value,
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined, sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
isPrimary: fields.isPrimary?.value === 'true', isPrimary: fields.isPrimary?.value === 'true',
@@ -98,11 +96,25 @@ export async function registerAdminImageRoutes(app, options) {
return admin; return admin;
} }
app.patch('/images/:id/order', async (request) => { app.post('/images/:id/move', async (request, reply) => {
const admin = await authorizeAdminMutation(request); const admin = await authorizeAdminMutation(request);
const result = moveInventoryImageSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.move({
imageId: request.params.id,
direction: result.data.direction,
sellerId: admin.sellerId,
});
return { data: toAdminInventoryImageDto(image) };
});
app.patch('/images/:id/order', async (request, reply) => {
const admin = await authorizeAdminMutation(request);
const result = updateImageOrderSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.updateOrder({ const image = await inventoryImageService.updateOrder({
imageId: request.params.id, imageId: request.params.id,
sortOrder: request.body?.sortOrder, sortOrder: result.data.sortOrder,
sellerId: admin.sellerId, sellerId: admin.sellerId,
}); });
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
@@ -117,11 +129,13 @@ export async function registerAdminImageRoutes(app, options) {
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
}); });
app.patch('/images/:id/alt-text', async (request) => { app.patch('/images/:id/alt-text', async (request, reply) => {
const admin = await authorizeAdminMutation(request); const admin = await authorizeAdminMutation(request);
const result = updateImageAltTextSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.updateAltText({ const image = await inventoryImageService.updateAltText({
imageId: request.params.id, imageId: request.params.id,
altText: request.body?.altText ?? null, altText: result.data.altText,
sellerId: admin.sellerId, sellerId: admin.sellerId,
}); });
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
@@ -1,5 +1,8 @@
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory'; import {
bulkInventorySchema,
createInventoryItemSchema,
} from '@pokemon-inventory/validation/inventory';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -58,4 +61,44 @@ export async function registerAdminInventoryRoutes(app, options) {
app.get('/inventory', async (request) => ({ app.get('/inventory', async (request) => ({
data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto), data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto),
})); }));
app.post('/bulk', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const result = bulkInventorySchema.safeParse(request.body);
if (!result.success) {
return validationError(reply, result);
}
const inventoryItems = result.data.action === 'ARCHIVE'
? await inventoryItemService.archiveMany({
inventoryItemIds: result.data.inventoryItemIds,
sellerId: request.admin.sellerId,
})
: result.data.action === 'PRICING_REFRESH'
? await inventoryItemService.refreshPricingMany({
inventoryItemIds: result.data.inventoryItemIds,
providerId: result.data.providerId,
sellerId: request.admin.sellerId,
})
: await inventoryItemService.updateTagsMany({
inventoryItemIds: result.data.inventoryItemIds,
sellerId: request.admin.sellerId,
addTags: result.data.add ?? [],
removeTags: result.data.remove ?? [],
});
if (result.data.action === 'ARCHIVE') {
return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) });
}
if (result.data.action === 'TAGS') return reply.send({ data: inventoryItems });
return reply.code(202).send({
data: inventoryItems.map(({ id, status, createdAt }) => ({ id, status, createdAt })),
});
});
} }
@@ -1,11 +1,13 @@
export async function registerPublicLookupRoutes(app, options) { export async function registerPublicLookupRoutes(app, options) {
const { catalogLookupRepository } = options; const { catalogLookupRepository } = options;
app.get('/public/lookups/categories', async () => ({ app.get("/public/lookups/categories", async () => ({
data: await catalogLookupRepository.listCategories(), data: await catalogLookupRepository.listCategories(),
})); }));
app.get('/public/lookups/conditions', async () => ({ app.get("/public/lookups/conditions", async () => ({
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })), data: (await catalogLookupRepository.listConditions()).map(
({ id, name, code }) => ({ id, name, code }),
),
})); }));
} }
@@ -1,13 +1,21 @@
import { validateImageMove } from '../domain/image-order.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js'; import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage }) { export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector, sellerSettingsRepository }) {
return { return {
async create(input) { async create(input) {
validateInventoryImageMetadata(input); validateInventoryImageMetadata(input);
const image = await inventoryImageRepository.createForSeller( const settingsRecord = await sellerSettingsRepository?.findBySellerId(input.sellerId);
const maxImages = settingsRecord?.settings?.inventory?.maxImagesPerItem ?? 10;
if (!Number.isInteger(maxImages) || maxImages < 1 || maxImages > 100) {
throw createApplicationError('IMAGE_COUNT_LIMIT_INVALID', 'Image count limit is invalid.', 500);
}
const createImage = inventoryImageRepository.createForSellerWithLimit ?? inventoryImageRepository.createForSeller;
const image = await createImage.call(inventoryImageRepository,
{ ...input, processingStatus: 'PENDING' }, { ...input, processingStatus: 'PENDING' },
input.sellerId, input.sellerId,
maxImages,
); );
if (!image) { if (!image) {
@@ -29,8 +37,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
async upload(input) { async upload(input) {
const { data, ...metadata } = input; const { data, ...metadata } = input;
validateInventoryImageMetadata(metadata);
validateImageSignature(data, metadata.mimeType); validateImageSignature(data, metadata.mimeType);
// Bound work before decoding; client dimensions and byte counts are not authoritative.
validateInventoryImageMetadata({ ...metadata, fileSize: data.length, width: 1, height: 1 });
let inspected;
try { inspected = await imageInspector.readMetadata(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
if (inspected.mimeType !== metadata.mimeType) {
throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image format does not match the declared MIME type.', 400);
}
Object.assign(metadata, { width: inspected.width, height: inspected.height, fileSize: data.length });
validateInventoryImageMetadata(metadata);
try { await imageInspector.validateDecoding(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
await imageStorage.put({ key: metadata.storageKey, data }); await imageStorage.put({ key: metadata.storageKey, data });
@@ -42,7 +61,13 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
} }
}, },
async move({ imageId, direction, sellerId }) {
validateImageMove(direction);
return requireOwnedImage(inventoryImageRepository.moveForSeller(imageId, direction, sellerId));
},
async updateOrder({ imageId, sortOrder, sellerId }) { async updateOrder({ imageId, sortOrder, sellerId }) {
validateImageSortOrder(sortOrder);
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId)); return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
}, },
@@ -51,7 +76,7 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
}, },
async updateAltText({ imageId, altText, sellerId }) { async updateAltText({ imageId, altText, sellerId }) {
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, altText, sellerId)); return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, normalizeImageAltText(altText), sellerId));
}, },
async delete({ imageId, sellerId }) { async delete({ imageId, sellerId }) {
@@ -81,4 +106,4 @@ async function requireOwnedImage(imagePromise) {
const image = await imagePromise; const image = await imagePromise;
if (!image) throw imageNotFoundError(); if (!image) throw imageNotFoundError();
return image; return image;
} }
@@ -1,10 +1,11 @@
import { createInventoryItem } from '../domain/inventory-item.js'; import { archiveInventoryItem, createInventoryItem } from '../domain/inventory-item.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createInventoryItemService({ export function createInventoryItemService({
conditionRepository, conditionRepository,
inventoryItemRepository, inventoryItemRepository,
productRepository, productRepository,
pricingService,
}) { }) {
return { return {
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
@@ -35,5 +36,45 @@ export function createInventoryItemService({
return inventoryItemRepository.create(inventoryItem); return inventoryItemRepository.create(inventoryItem);
}, },
async archiveMany({ inventoryItemIds, sellerId }) {
const items = await inventoryItemRepository.listBySellerId(sellerId);
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
if (requestedItems.length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
requestedItems.forEach(({ status }) => archiveInventoryItem(status));
return inventoryItemRepository.archiveByIdsForSeller(inventoryItemIds, sellerId);
},
async refreshPricingMany({ inventoryItemIds, sellerId, providerId }) {
if (!pricingService) {
throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
}
const items = await inventoryItemRepository.listBySellerId(sellerId);
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
if (requestedItems.length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
return Promise.all(requestedItems.map((item) => pricingService.enqueueRefresh({
sellerId,
inventoryItemId: item.id,
providerId,
productId: item.productId,
conditionId: item.conditionId,
})));
},
async updateTagsMany({ inventoryItemIds, sellerId, addTags = [], removeTags = [] }) {
const items = await inventoryItemRepository.listBySellerId(sellerId);
if (items.filter(({ id }) => inventoryItemIds.includes(id)).length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
return inventoryItemRepository.updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags);
},
}; };
} }
@@ -1,4 +1,14 @@
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm'; import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql,
} from "drizzle-orm";
import { import {
conditions, conditions,
@@ -7,12 +17,17 @@ import {
marketValueOverrides, marketValueOverrides,
marketValuations, marketValuations,
products, products,
} from '../../../infrastructure/database/schema.js'; sellers,
sellerSettings,
} from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) { export function createListingRepository(db) {
return { return {
async create(listing) { async create(listing) {
const [createdListing] = await db.insert(listings).values(listing).returning(); const [createdListing] = await db
.insert(listings)
.values(listing)
.returning();
return createdListing; return createdListing;
}, },
@@ -28,18 +43,28 @@ export function createListingRepository(db) {
const [result] = await db const [result] = await db
.select({ listing: listings, inventoryStatus: inventoryItems.status }) .select({ listing: listings, inventoryStatus: inventoryItems.status })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId))); .where(
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
);
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null; return result
? { ...result.listing, inventoryStatus: result.inventoryStatus }
: null;
}, },
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
const results = await db const results = await db
.select({ listing: listings }) .select({ listing: listings })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(listings.createdAt)); .orderBy(desc(listings.createdAt));
@@ -49,43 +74,75 @@ export function createListingRepository(db) {
async searchPublic(filters = {}) { async searchPublic(filters = {}) {
const { const {
text = '', text = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
limit = 20, limit = 20,
includeSold = false, includeSold = false,
sort = 'recent', sort = "recent",
} = filters; } = filters;
const conditions = [ const conditions = [
inArray( inArray(
listings.status, listings.status,
includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'], status === "SOLD"
? ["CLOSED"]
: status === "PENDING"
? ["PENDING"]
: status === "AVAILABLE"
? ["AVAILABLE"]
: includeSold
? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"],
),
inArray(
inventoryItems.status,
status === "SOLD" ? ["SOLD"] : includeSold || status === "ALL" ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
), ),
inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']),
]; ];
if (typeof category === 'string' && category.trim().length > 0) { if (newlyAdded) {
conditions.push(sql`${inventoryItems.createdAt} >= NOW() - INTERVAL '30 days'`);
}
if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category)); conditions.push(eq(products.categoryId, category));
} }
if (typeof conditionId === 'string' && conditionId.trim().length > 0) { if (typeof conditionId === "string" && conditionId.trim().length > 0) {
conditions.push(eq(inventoryItems.conditionId, conditionId)); conditions.push(eq(inventoryItems.conditionId, conditionId));
} }
if (typeof minPriceMinor === 'number') { if (typeof setName === "string" && setName.trim().length > 0) {
conditions.push(ilike(products.setName, setName));
}
if (typeof eventName === "string" && eventName.trim().length > 0) {
conditions.push(ilike(products.eventName, eventName));
}
if (typeof tag === "string" && tag.trim().length > 0) {
conditions.push(sql`${products.tags}::text ILIKE ${`%${tag}%`}`);
}
if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
} }
if (typeof maxPriceMinor === 'number') { if (typeof maxPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`); conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
} }
if (typeof text === 'string' && text.trim().length > 0) { if (typeof text === "string" && text.trim().length > 0) {
const searchTerms = text.split(' ').filter(Boolean); const searchTerms = text.split(" ").filter(Boolean);
if (searchTerms.length > 0) { if (searchTerms.length > 0) {
conditions.push( conditions.push(
@@ -97,6 +154,7 @@ export function createListingRepository(db) {
ilike(products.description, `%${term}%`), ilike(products.description, `%${term}%`),
ilike(products.setName, `%${term}%`), ilike(products.setName, `%${term}%`),
ilike(products.brand, `%${term}%`), ilike(products.brand, `%${term}%`),
sql`${products.tags}::text ILIKE ${`%${term}%`}`,
), ),
), ),
), ),
@@ -105,28 +163,55 @@ export function createListingRepository(db) {
} }
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const sortOrder = sort === 'price_low' const marketValue = sql`COALESCE(
? listings.askingPriceMinor (SELECT ${marketValueOverrides.valueMinor}
: sort === 'price_high' FROM ${marketValueOverrides}
? desc(listings.askingPriceMinor) WHERE ${marketValueOverrides.inventoryItemId} = ${inventoryItems.id}
: desc(listings.createdAt); ORDER BY ${marketValueOverrides.createdAt} DESC LIMIT 1),
(SELECT ${marketValuations.valueMinor}
FROM ${marketValuations}
WHERE ${marketValuations.inventoryItemId} = ${inventoryItems.id}
ORDER BY ${marketValuations.calculatedAt} DESC LIMIT 1)
)`;
const sortOrder =
sort === "updated"
? desc(listings.updatedAt)
: sort === "name_asc"
? products.name
: sort === "name_desc"
? desc(products.name)
: sort === "market_low"
? sql`${marketValue} ASC NULLS LAST`
: sort === "market_high"
? sql`${marketValue} DESC NULLS LAST`
: sort === "price_low"
? listings.askingPriceMinor
: sort === "price_high"
? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const [countResult, results] = await Promise.all([ const [countResult, results] = await Promise.all([
db db
.select({ total: count() }) .select({ total: count() })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions)), .where(and(...conditions)),
db db
.select({ listing: listings }) .select({ listing: listings })
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
.innerJoin(products, eq(products.id, inventoryItems.productId)) inventoryItems,
.where(and(...conditions)) eq(inventoryItems.id, listings.inventoryItemId),
.orderBy(sortOrder, desc(listings.createdAt)) )
.limit(limit) .innerJoin(products, eq(products.id, inventoryItems.productId))
.offset(offset), .where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
]); ]);
return { return {
@@ -137,30 +222,60 @@ export function createListingRepository(db) {
async findPublicById(listingId) { async findPublicById(listingId) {
const [result] = await db const [result] = await db
.select({ listing: listings, condition: conditions }) .select({
listing: listings,
condition: conditions,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(listings) .from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId)) .innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.where(and( .innerJoin(sellers, eq(sellers.id, products.sellerId))
eq(listings.id, listingId), .leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
inArray(listings.status, ['AVAILABLE', 'PENDING']), .where(
eq(inventoryItems.status, 'ACTIVE'), and(
)); eq(listings.id, listingId),
inArray(listings.status, ["AVAILABLE", "PENDING"]),
eq(inventoryItems.status, "ACTIVE"),
),
);
if (!result) return null; if (!result) return null;
const [override] = await db.select().from(marketValueOverrides) const [override] = await db
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId)) .select()
.orderBy(desc(marketValueOverrides.createdAt)).limit(1); .from(marketValueOverrides)
const [valuation] = await db.select().from(marketValuations) .where(
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId)) eq(
.orderBy(desc(marketValuations.calculatedAt)).limit(1); marketValueOverrides.inventoryItemId,
result.listing.inventoryItemId,
),
)
.orderBy(desc(marketValueOverrides.createdAt))
.limit(1);
const [valuation] = await db
.select()
.from(marketValuations)
.where(
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
)
.orderBy(desc(marketValuations.calculatedAt))
.limit(1);
return { return {
...result.listing, ...result.listing,
conditionName: result.condition.name, conditionName: result.condition.name,
conditionCode: result.condition.code, conditionCode: result.condition.code,
marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null, marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null,
marketValueCurrency: override?.currency ?? valuation?.currency ?? null, marketValueCurrency: override?.currency ?? valuation?.currency ?? null,
marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null,
sellerDisplayName: result.sellerDisplayName,
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
}; };
}, },
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) { function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`); throw new Error(`${fieldName} is required.`);
} }
@@ -19,28 +19,30 @@ export function createPricingObservation({
inventoryItemId, inventoryItemId,
providerId, providerId,
priceMinor, priceMinor,
currency = 'USD', currency = "USD",
sourceIdentifier, sourceIdentifier,
conditionId, conditionId,
sourceUrl, sourceUrl,
qualityScore, qualityScore,
}) { }) {
return { return {
productId: requiredText(productId, 'Product ID'), productId: requiredText(productId, "Product ID"),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'), inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
providerId: requiredText(providerId, 'Provider ID'), providerId: requiredText(providerId, "Provider ID"),
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'), priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
currency: requiredText(currency, 'Currency'), currency: requiredText(currency, "Currency"),
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'), sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
conditionId: requiredText(conditionId, 'Condition ID'), conditionId: requiredText(conditionId, "Condition ID"),
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}), ...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
...(typeof qualityScore === 'number' ? { qualityScore } : {}), ? { sourceUrl: sourceUrl.trim() }
: {}),
...(typeof qualityScore === "number" ? { qualityScore } : {}),
}; };
} }
export function calculateMedianPriceMinor(prices) { export function calculateMedianPriceMinor(prices) {
if (!Array.isArray(prices) || prices.length === 0) { if (!Array.isArray(prices) || prices.length === 0) {
throw new Error('At least one price is required.'); throw new Error("At least one price is required.");
} }
const sorted = [...prices].sort((left, right) => left - right); const sorted = [...prices].sort((left, right) => left - right);
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
if (!Array.isArray(prices) || prices.length < 4) return new Set(); if (!Array.isArray(prices) || prices.length < 4) return new Set();
const sorted = [...prices].sort((left, right) => left - right); const sorted = [...prices].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2); const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2 const median =
? sorted[middle] sorted.length % 2
: (sorted[middle - 1] + sorted[middle]) / 2; ? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const lowerFence = median / 3; const lowerFence = median / 3;
const upperFence = median * 3; const upperFence = median * 3;
return new Set(prices.filter((price) => price < lowerFence || price > upperFence)); return new Set(
prices.filter((price) => price < lowerFence || price > upperFence),
);
} }
@@ -1,18 +1,20 @@
import { createPricingProvider } from './pricing-provider.js'; import { createPricingProvider } from "./pricing-provider.js";
export function createFixturePricingProvider() { export function createFixturePricingProvider() {
return createPricingProvider({ return createPricingProvider({
id: 'fixture-provider', id: "fixture-provider",
async observe({ productId, inventoryItemId, conditionId }) { async observe({ productId, inventoryItemId, conditionId }) {
return [{ return [
productId, {
inventoryItemId, productId,
conditionId, inventoryItemId,
priceMinor: 2500, conditionId,
currency: 'USD', priceMinor: 2500,
sourceIdentifier: `fixture-${inventoryItemId}`, currency: "USD",
qualityScore: 50, sourceIdentifier: `fixture-${inventoryItemId}`,
}]; qualityScore: 50,
},
];
}, },
}); });
} }
@@ -1,5 +1,23 @@
import { createPricingProvider } from './pricing-provider.js'; import { createPricingProvider } from './pricing-provider.js';
export function assertProductionPricingProviderConfig({ fixtureEnabled, endpoint }) {
if (fixtureEnabled) {
throw new Error('The fixture pricing provider must be disabled in production.');
}
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
throw new Error('A production pricing provider endpoint is required.');
}
let parsedUrl;
try {
parsedUrl = new URL(endpoint);
} catch {
throw new Error('The production pricing provider endpoint must be a valid HTTPS URL.');
}
if (parsedUrl.protocol !== 'https:') {
throw new Error('The production pricing provider endpoint must use HTTPS.');
}
}
export function createHttpPricingProvider({ export function createHttpPricingProvider({
id, id,
endpoint, endpoint,
@@ -10,10 +28,21 @@ export function createHttpPricingProvider({
throw new Error('Pricing provider endpoint is required.'); throw new Error('Pricing provider endpoint is required.');
} }
let parsedUrl;
try {
parsedUrl = new URL(endpoint);
} catch {
throw new Error('Pricing provider endpoint must be a valid HTTPS URL.');
}
if (parsedUrl.protocol !== 'https:') {
throw new Error('Pricing provider endpoint must use HTTPS.');
}
return createPricingProvider({ return createPricingProvider({
id, id,
async observe(input) { async observe(input) {
const url = new URL(endpoint); const url = new URL(parsedUrl.toString());
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
if (value) url.searchParams.set(key, value); if (value) url.searchParams.set(key, value);
} }
@@ -1,10 +1,10 @@
export function createPricingProvider({ id, observe }) { export function createPricingProvider({ id, observe }) {
if (typeof id !== 'string' || id.trim().length === 0) { if (typeof id !== "string" || id.trim().length === 0) {
throw new Error('Pricing provider id is required.'); throw new Error("Pricing provider id is required.");
} }
if (typeof observe !== 'function') { if (typeof observe !== "function") {
throw new TypeError('Pricing provider observe function is required.'); throw new TypeError("Pricing provider observe function is required.");
} }
return Object.freeze({ return Object.freeze({
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
async observe(input) { async observe(input) {
const observations = await observe(input); const observations = await observe(input);
if (!Array.isArray(observations)) { if (!Array.isArray(observations)) {
throw new Error(`Pricing provider ${id} must return an observation array.`); throw new Error(
`Pricing provider ${id} must return an observation array.`,
);
} }
return observations; return observations;
}, },
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
} }
export function createPricingProviderRegistry(providers = []) { export function createPricingProviderRegistry(providers = []) {
const providerMap = new Map(providers.map((provider) => [provider.id, provider])); const providerMap = new Map(
providers.map((provider) => [provider.id, provider]),
);
return { return {
get(providerId) { get(providerId) {
@@ -1,95 +1,180 @@
import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm'; import { and, asc, desc, eq, isNull, lte, or } from "drizzle-orm";
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js'; import { pricingRefreshJobs } from "../../../infrastructure/database/schema.js";
export function createPricingRefreshJobRepository(db) { export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3; const maxAttempts = 3;
return { return {
async create(job) { async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning(); const [created] = await db
.insert(pricingRefreshJobs)
.values(job)
.returning();
return created; return created;
}, },
async claimNext() { async claimNext() {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs) const [job] = await transaction
.where(and( .select()
eq(pricingRefreshJobs.status, 'PENDING'), .from(pricingRefreshJobs)
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())), .where(
)) and(
eq(pricingRefreshJobs.status, "PENDING"),
or(
isNull(pricingRefreshJobs.nextAttemptAt),
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
),
),
)
.orderBy(asc(pricingRefreshJobs.createdAt)) .orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1) .limit(1)
.for('update', { skipLocked: true }); .for("update", { skipLocked: true });
if (!job) return null; if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs) const [claimed] = await transaction
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING'))) .set({
status: "PROCESSING",
attemptCount: pricingRefreshJobs.attemptCount,
updatedAt: new Date(),
error: null,
nextAttemptAt: null,
})
.where(
and(
eq(pricingRefreshJobs.id, job.id),
eq(pricingRefreshJobs.status, "PENDING"),
),
)
.returning(); .returning();
return claimed ?? null; return claimed ?? null;
}); });
}, },
async findByIdForSeller(id, sellerId) { async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(pricingRefreshJobs) const [job] = await db
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId))); .select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
),
);
return job ?? null; return job ?? null;
}, },
async countFailedForSeller(sellerId) { async countFailedForSeller(sellerId) {
const rows = await db.select({ id: pricingRefreshJobs.id }) const rows = await db
.select({ id: pricingRefreshJobs.id })
.from(pricingRefreshJobs) .from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'))); .where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
),
);
return rows.length; return rows.length;
}, },
async listForSeller(sellerId) { async listForSeller(sellerId) {
return db.select().from(pricingRefreshJobs) return db
.select()
.from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.sellerId, sellerId)) .where(eq(pricingRefreshJobs.sellerId, sellerId))
.orderBy(desc(pricingRefreshJobs.createdAt)); .orderBy(desc(pricingRefreshJobs.createdAt));
}, },
async retryForSeller(id, sellerId) { async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs) const [job] = await db
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1))) .set({
status: "PENDING",
error: null,
observations: [],
valuation: null,
nextAttemptAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
),
)
.returning(); .returning();
return job ?? null; return job ?? null;
}, },
async complete(id, observations, valuation) { async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs) const [completed] = await db
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() }) .update(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))) .set({
status: "COMPLETED",
observations,
valuation,
error: null,
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning(); .returning();
return completed ?? null; return completed ?? null;
}, },
async fail(id, message) { async fail(id, message) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [current] = await transaction.select().from(pricingRefreshJobs) const [current] = await transaction
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))); .select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
);
if (!current) return null; if (!current) return null;
const attemptCount = current.attemptCount + 1; const attemptCount = current.attemptCount + 1;
const retryable = attemptCount < maxAttempts; const retryable = attemptCount < maxAttempts;
const [updated] = await transaction.update(pricingRefreshJobs) const [updated] = await transaction
.update(pricingRefreshJobs)
.set({ .set({
status: retryable ? 'PENDING' : 'FAILED', status: retryable ? "PENDING" : "FAILED",
error: message, error: message,
attemptCount, attemptCount,
updatedAt: new Date(), updatedAt: new Date(),
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null, nextAttemptAt: retryable
? new Date(Date.now() + 2 ** attemptCount * 1000)
: null,
}) })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))) .where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning(); .returning();
return updated ?? null; return updated ?? null;
}); });
}, },
async purgeCompletedBefore(sellerId, cutoff) { async purgeCompletedBefore(cutoff) {
const deleted = await db.delete(pricingRefreshJobs) const deleted = await db
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff))) .delete(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.status, "COMPLETED"),
lte(pricingRefreshJobs.updatedAt, cutoff),
),
)
.returning({ id: pricingRefreshJobs.id }); .returning({ id: pricingRefreshJobs.id });
return deleted.length; return deleted.length;
}, },
}; };
} }
@@ -1,7 +1,14 @@
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray } from "drizzle-orm";
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js'; import {
import { identifyOutlierPrices } from '../domain/pricing-observation.js'; inventoryItems,
marketValueOverrides,
marketValuations,
pricingObservations,
products,
securityAuditEvents,
} from "../../../infrastructure/database/schema.js";
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
export function createPricingRepository(db) { export function createPricingRepository(db) {
return { return {
@@ -10,77 +17,165 @@ export function createPricingRepository(db) {
.select({ id: inventoryItems.id, productId: inventoryItems.productId }) .select({ id: inventoryItems.id, productId: inventoryItems.productId })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' }; and(
const [created] = await db.insert(pricingObservations).values(observation).returning(); eq(inventoryItems.id, observation.inventoryItemId),
return { status: 'CREATED', observation: created }; eq(products.sellerId, sellerId),
),
);
if (!ownedItem || ownedItem.productId !== observation.productId)
return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingObservations)
.values(observation)
.returning();
return { status: "CREATED", observation: created };
}, },
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) { async calculateForSeller({
inventoryItemId,
sellerId,
strategy = "MEDIAN",
}) {
const [ownedItem] = await db const [ownedItem] = await db
.select({ id: inventoryItems.id }) .select({ id: inventoryItems.id })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const observations = await db.select().from(pricingObservations) eq(inventoryItems.id, inventoryItemId),
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false))) eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
eq(pricingObservations.isOutlier, false),
),
)
.orderBy(desc(pricingObservations.observedAt)); .orderBy(desc(pricingObservations.observedAt));
if (!observations.length) return { status: 'NO_DATA' }; if (!observations.length) return { status: "NO_DATA" };
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right); const prices = observations
.map(({ priceMinor }) => priceMinor)
.sort((left, right) => left - right);
const middle = Math.floor(prices.length / 2); const middle = Math.floor(prices.length / 2);
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2); const valueMinor =
const [valuation] = await db.insert(marketValuations).values({ prices.length % 2
inventoryItemId, ? prices[middle]
valueMinor, : Math.round((prices[middle - 1] + prices[middle]) / 2);
currency: 'USD', const [valuation] = await db
strategy, .insert(marketValuations)
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM', .values({
sourceCount: observations.length, inventoryItemId,
}).returning(); valueMinor,
return { status: 'CALCULATED', valuation, observations }; currency: "USD",
strategy,
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
sourceCount: observations.length,
})
.returning();
return { status: "CALCULATED", valuation, observations };
}, },
async listLatestValuationsForSeller(sellerId) { async listLatestValuationsForSeller(sellerId) {
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id }) const rows = await db
.select({
valuation: marketValuations,
inventoryItemId: inventoryItems.id,
})
.from(marketValuations) .from(marketValuations)
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, marketValuations.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId)) .where(eq(products.sellerId, sellerId))
.orderBy(desc(marketValuations.calculatedAt)); .orderBy(desc(marketValuations.calculatedAt));
const latest = new Map(); const latest = new Map();
for (const row of rows) { for (const row of rows) {
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation); if (!latest.has(row.inventoryItemId))
latest.set(row.inventoryItemId, row.valuation);
} }
return [...latest.values()]; return [...latest.values()];
}, },
async markOutliersForSeller({ inventoryItemId, sellerId }) { async markOutliersForSeller({ inventoryItemId, sellerId }) {
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems) const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId)); eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
const prices = observations.map(({ priceMinor }) => priceMinor); const prices = observations.map(({ priceMinor }) => priceMinor);
const outlierPrices = identifyOutlierPrices(prices); const outlierPrices = identifyOutlierPrices(prices);
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId)); await db
.update(pricingObservations)
.set({ isOutlier: false, outlierReason: null })
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
if (outlierPrices.size) { if (outlierPrices.size) {
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' }) await db
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices]))); .update(pricingObservations)
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
inArray(pricingObservations.priceMinor, [...outlierPrices]),
),
);
} }
return { status: 'UPDATED' }; return { status: "UPDATED" };
}, },
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) { async createOverride({
inventoryItemId,
sellerId,
actorUserId,
valueMinor,
currency,
reason,
}) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems) const [ownedItem] = await transaction
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId))); .where(
if (!ownedItem) return { status: 'NOT_FOUND' }; and(
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning(); eq(inventoryItems.id, inventoryItemId),
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } }); eq(products.sellerId, sellerId),
return { status: 'CREATED', override }; ),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const [override] = await transaction
.insert(marketValueOverrides)
.values({
inventoryItemId,
valueMinor,
currency,
reason,
createdBy: actorUserId,
})
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: "MARKET_VALUE_OVERRIDDEN",
metadata: { inventoryItemId, overrideId: override.id, reason },
});
return { status: "CREATED", override };
}); });
}, },
}; };
} }
@@ -1,25 +1,42 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from "drizzle-orm";
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js'; import {
pricingProviderMappings,
products,
} from "../../../infrastructure/database/schema.js";
export function createProviderMappingRepository(db) { export function createProviderMappingRepository(db) {
return { return {
async createForSeller(mapping, sellerId) { async createForSeller(mapping, sellerId) {
const [ownedProduct] = await db.select({ id: products.id }).from(products) const [ownedProduct] = await db
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId))); .select({ id: products.id })
if (!ownedProduct) return { status: 'NOT_FOUND' }; .from(products)
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning(); .where(
return { status: 'CREATED', mapping: created }; and(
eq(products.id, mapping.productId),
eq(products.sellerId, sellerId),
),
);
if (!ownedProduct) return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingProviderMappings)
.values(mapping)
.returning();
return { status: "CREATED", mapping: created };
}, },
async listForSeller(productId, sellerId) { async listForSeller(productId, sellerId) {
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) return db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and( .where(
eq(pricingProviderMappings.productId, productId), and(
eq(products.sellerId, sellerId), eq(pricingProviderMappings.productId, productId),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(products.sellerId, sellerId),
)) eq(pricingProviderMappings.status, "ACTIVE"),
),
)
.then((rows) => rows.map(({ mapping }) => mapping)); .then((rows) => rows.map(({ mapping }) => mapping));
}, },
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
const conditions = [ const conditions = [
eq(pricingProviderMappings.productId, productId), eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId), eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(pricingProviderMappings.status, "ACTIVE"),
]; ];
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId)); if (providerId)
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings) conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(...conditions)); .where(and(...conditions));
return row?.mapping ?? null; return row?.mapping ?? null;
}, },
async disableForSeller(id, sellerId) { async disableForSeller(id, sellerId) {
const [mapping] = await db.select().from(pricingProviderMappings) const [mapping] = await db
.select()
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId)) .innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and( .where(
eq(pricingProviderMappings.id, id), and(
eq(products.sellerId, sellerId), eq(pricingProviderMappings.id, id),
eq(pricingProviderMappings.status, 'ACTIVE'), eq(products.sellerId, sellerId),
)); eq(pricingProviderMappings.status, "ACTIVE"),
if (!mapping) return { status: 'NOT_FOUND' }; ),
const [updated] = await db.update(pricingProviderMappings) );
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() }) if (!mapping) return { status: "NOT_FOUND" };
const [updated] = await db
.update(pricingProviderMappings)
.set({
status: "DISABLED",
disabledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pricingProviderMappings.id, id)) .where(eq(pricingProviderMappings.id, id))
.returning(); .returning();
return { status: 'DISABLED', mapping: updated }; return { status: "DISABLED", mapping: updated };
}, },
}; };
} }
@@ -1,48 +1,86 @@
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing'; import {
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing'; toAdminMarketValuationDto,
toAdminMarketValuationSummaryDto,
toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing";
import {
createMarketValueOverrideSchema,
createPricingObservationSchema,
} from "@pokemon-inventory/validation/pricing";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminPricingRoutes(app, options) { export async function registerAdminPricingRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
pricingService, pricingService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.post('/pricing/observations', async (request, reply) => { app.post("/pricing/observations", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createPricingObservationSchema.safeParse(request.body); const result = createPricingObservationSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId }); const observation = await pricingService.addObservation({
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) }); ...result.data,
sellerId: request.admin.sellerId,
});
return reply
.code(201)
.send({ data: toAdminPricingObservationDto(observation) });
}); });
app.post('/inventory/:id/valuation', async (request, reply) => { app.post("/inventory/:id/valuation", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id }); request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const valuation = await pricingService.calculate({
sellerId: request.admin.sellerId,
inventoryItemId: request.params.id,
});
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) }); return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
}); });
app.post('/inventory/:id/valuation/override', async (request, reply) => { app.post("/inventory/:id/valuation/override", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createMarketValueOverrideSchema.safeParse(request.body); const result = createMarketValueOverrideSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const override = await pricingService.override({ const override = await pricingService.override({
@@ -54,8 +92,19 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override }); return reply.code(201).send({ data: override });
}); });
app.post('/inventory/:id/pricing/refresh', async (request, reply) => { app.get("/valuations", async (request) => ({
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); data: (
await pricingService.listValuations({
sellerId: request.admin.sellerId,
})
).map(toAdminMarketValuationSummaryDto),
}));
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 { providerId, productId, conditionId } = request.body ?? {};
const job = await pricingService.enqueueRefresh({ const job = await pricingService.enqueueRefresh({
sellerId: request.admin.sellerId, sellerId: request.admin.sellerId,
@@ -64,11 +113,16 @@ export async function registerAdminPricingRoutes(app, options) {
productId, productId,
conditionId, conditionId,
}); });
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } }); return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
}); });
app.get('/jobs/:id', async (request) => { app.get("/jobs/:id", async (request) => {
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id }); const job = await pricingService.getJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return { return {
data: { data: {
id: job.id, id: job.id,
@@ -81,16 +135,31 @@ export async function registerAdminPricingRoutes(app, options) {
}; };
}); });
app.get('/jobs', async (request) => ({ app.get("/jobs", async (request) => ({
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({ data: (
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId, await pricingService.listJobs({ sellerId: request.admin.sellerId })
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt, ).map((job) => ({
id: job.id,
status: job.status,
providerId: job.providerId,
inventoryItemId: job.inventoryItemId,
error: job.error ?? null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
})), })),
})); }));
app.post('/jobs/:id/retry', async (request, reply) => { app.post("/jobs/:id/retry", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id }); request.cookies[sessionCookieName],
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } }); request.headers["x-csrf-token"],
);
const job = await pricingService.retryJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
}); });
} }
@@ -1,47 +1,79 @@
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping'; import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping'; import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
export async function registerAdminProviderMappingRoutes(app, options) { export async function registerAdminProviderMappingRoutes(app, options) {
const { const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest, authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
providerMappingService, providerMappingService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin; request.admin = admin;
}); });
app.get('/products/:id/provider-mappings', async (request) => ({ app.get("/products/:id/provider-mappings", async (request) => ({
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto), data: (
await providerMappingService.list({
productId: request.params.id,
sellerId: request.admin.sellerId,
})
).map(toAdminProviderMappingDto),
})); }));
app.post('/products/:id/provider-mappings', async (request, reply) => { app.post("/products/:id/provider-mappings", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createProviderMappingSchema.safeParse(request.body); const result = createProviderMappingSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId }); const mapping = await providerMappingService.create({
...result.data,
productId: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) }); return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
}); });
app.post('/provider-mappings/:id/disable', async (request, reply) => { app.post("/provider-mappings/:id/disable", async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); await authorizeUnsafeRequest?.(
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId }); request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const mapping = await providerMappingService.disable({
id: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) }); return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
}); });
} }
@@ -1,66 +1,184 @@
import { createPricingObservation } from '../domain/pricing-observation.js'; import { createPricingObservation } from "../domain/pricing-observation.js";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) { export function createPricingService({
pricingRepository,
pricingProviderRegistry,
pricingRefreshJobRepository,
providerMappingRepository,
}) {
return { return {
async addObservation({ sellerId, ...input }) { async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input); const observation = createPricingObservation(input);
const result = await pricingRepository.addObservationForSeller({ observation, sellerId }); const result = await pricingRepository.addObservationForSeller({
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); observation,
sellerId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.observation; return result.observation;
}, },
async markOutliers({ sellerId, inventoryItemId }) { async markOutliers({ sellerId, inventoryItemId }) {
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId }); const result = await pricingRepository.markOutliersForSeller({
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result; return result;
}, },
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) { async override({
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400); sellerId,
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400); actorUserId,
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() }); inventoryItemId,
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); valueMinor,
currency = "USD",
reason,
}) {
if (!Number.isInteger(valueMinor) || valueMinor < 0)
throw createApplicationError(
"INVALID_OVERRIDE_VALUE",
"Override value must be a non-negative integer minor unit.",
400,
);
if (typeof reason !== "string" || reason.trim().length < 3)
throw createApplicationError(
"OVERRIDE_REASON_REQUIRED",
"An override reason is required.",
400,
);
const result = await pricingRepository.createOverride({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency: currency.toUpperCase(),
reason: reason.trim(),
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.override; return result.override;
}, },
async calculate({ sellerId, inventoryItemId }) { async calculate({ sellerId, inventoryItemId }) {
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId }); await pricingRepository.markOutliersForSeller({
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId }); sellerId,
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); inventoryItemId,
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404); });
const result = await pricingRepository.calculateForSeller({
sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
if (result.status === "NO_DATA")
throw createApplicationError(
"PRICING_DATA_UNAVAILABLE",
"No pricing observations are available.",
404,
);
return result; return result;
}, },
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { async refresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
const provider = pricingProviderRegistry?.get(providerId); const provider = pricingProviderRegistry?.get(providerId);
if (!provider) { if (!provider) {
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503); throw createApplicationError(
"PRICING_PROVIDER_UNAVAILABLE",
"Pricing provider is not configured.",
503,
);
} }
let providerObservations; let providerObservations;
try { try {
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId }); providerObservations = await provider.observe({
inventoryItemId,
productId,
conditionId,
});
} catch (error) { } catch (error) {
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502); throw createApplicationError(
"PRICING_PROVIDER_FAILED",
error.message,
502,
);
} }
const observations = []; const observations = [];
for (const input of providerObservations) { for (const input of providerObservations) {
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId })); observations.push(
await this.addObservation({
sellerId,
...input,
providerId: provider.id,
inventoryItemId,
productId,
conditionId,
}),
);
} }
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) }; return {
observations,
valuation: await this.calculate({ sellerId, inventoryItemId }),
};
}, },
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) { async enqueueRefresh({
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503); sellerId,
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId); inventoryItemId,
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409); providerId,
productId,
conditionId,
}) {
if (!pricingRefreshJobRepository)
throw createApplicationError(
"PRICING_QUEUE_UNAVAILABLE",
"Pricing refresh queue is unavailable.",
503,
);
const mapping = await providerMappingRepository?.findActiveForSeller(
productId,
sellerId,
providerId,
);
if (!mapping)
throw createApplicationError(
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
"No active pricing provider mapping exists for this product.",
409,
);
const job = await pricingRefreshJobRepository.create({ const job = await pricingRefreshJobRepository.create({
sellerId, sellerId,
inventoryItemId, inventoryItemId,
providerId: mapping.providerId, providerId: mapping.providerId,
externalIdentifier: mapping.externalIdentifier,
searchQuery: mapping.searchQuery,
productId, productId,
conditionId, conditionId,
}); });
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
}, },
async getJob({ sellerId, jobId }) { async getJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId); const job = await pricingRefreshJobRepository?.findByIdForSeller(
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404); jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_FOUND",
"Pricing job was not found.",
404,
);
return job; return job;
}, },
@@ -77,10 +203,22 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? []; return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
}, },
async listValuations({ sellerId }) {
return pricingRepository.listLatestValuationsForSeller(sellerId);
},
async retryJob({ sellerId, jobId }) { async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId); const job = await pricingRefreshJobRepository?.retryForSeller(
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409); jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_RETRYABLE",
"Pricing job was not found or is not failed.",
409,
);
return job; return job;
}, },
}; };
} }
@@ -1,18 +1,34 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createProviderMappingService({ providerMappingRepository }) { export function createProviderMappingService({ providerMappingRepository }) {
return { return {
async create({ sellerId, ...input }) { async create({ sellerId, ...input }) {
const result = await providerMappingRepository.createForSeller(input, sellerId); const result = await providerMappingRepository.createForSeller(
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404); input,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PRODUCT_NOT_FOUND",
"Product was not found.",
404,
);
return result.mapping; return result.mapping;
}, },
async list({ productId, sellerId }) { async list({ productId, sellerId }) {
return providerMappingRepository.listForSeller(productId, sellerId); return providerMappingRepository.listForSeller(productId, sellerId);
}, },
async disable({ id, sellerId }) { async disable({ id, sellerId }) {
const result = await providerMappingRepository.disableForSeller(id, sellerId); const result = await providerMappingRepository.disableForSeller(
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404); id,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PROVIDER_MAPPING_NOT_FOUND",
"Provider mapping was not found.",
404,
);
return result.mapping; return result.mapping;
}, },
}; };
@@ -6,11 +6,13 @@ function requiredText(value, fieldName) {
return value.trim(); return value.trim();
} }
export function createProduct({ name, categoryId, sellerId }) { export function createProduct({ name, categoryId, sellerId, tags = [] }) {
const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()))];
return { return {
name: requiredText(name, 'Product name'), name: requiredText(name, 'Product name'),
categoryId: requiredText(categoryId, 'Category ID'), categoryId: requiredText(categoryId, 'Category ID'),
sellerId: requiredText(sellerId, 'Seller ID'), sellerId: requiredText(sellerId, 'Seller ID'),
tags: normalizedTags,
}; };
} }
@@ -4,6 +4,8 @@ import {
inventoryItems, inventoryItems,
listings, listings,
products, products,
sellers,
sellerSettings,
} from '../../../infrastructure/database/schema.js'; } from '../../../infrastructure/database/schema.js';
export function createProductRepository(db) { export function createProductRepository(db) {
@@ -24,10 +26,16 @@ export function createProductRepository(db) {
async findPublicBySlug(slug) { async findPublicBySlug(slug) {
const [result] = await db const [result] = await db
.select({ product: products }) .select({
product: products,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(products) .from(products)
.innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(sellers, eq(sellers.id, products.sellerId))
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
.where(and( .where(and(
eq(products.slug, slug), eq(products.slug, slug),
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -35,7 +43,14 @@ export function createProductRepository(db) {
)) ))
.limit(1); .limit(1);
return result?.product ?? null; if (!result) return null;
return {
...result.product,
sellerDisplayName: result.sellerDisplayName,
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
};
}, },
async findBySellerIdAndSlug(sellerId, slug) { async findBySellerIdAndSlug(sellerId, slug) {
+53 -32
View File
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) { function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`); throw new Error(`${fieldName} is required.`);
} }
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
const [, year, month, day] = match; const [, year, month, day] = match;
const parsedDate = new Date(`${date}T00:00:00.000Z`); const parsedDate = new Date(`${date}T00:00:00.000Z`);
if ( if (
Number.isNaN(parsedDate.getTime()) Number.isNaN(parsedDate.getTime()) ||
|| parsedDate.getUTCFullYear() !== Number(year) parsedDate.getUTCFullYear() !== Number(year) ||
|| parsedDate.getUTCMonth() + 1 !== Number(month) parsedDate.getUTCMonth() + 1 !== Number(month) ||
|| parsedDate.getUTCDate() !== Number(day) parsedDate.getUTCDate() !== Number(day)
) { ) {
throw new Error(`${fieldName} must be a valid ISO date.`); throw new Error(`${fieldName} must be a valid ISO date.`);
} }
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
} }
function requireCurrency(value) { function requireCurrency(value) {
const currency = requiredText(value, 'Currency').toUpperCase(); const currency = requiredText(value, "Currency").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) { if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error('Currency must be a three-letter ISO currency code.'); throw new Error("Currency must be a three-letter ISO currency code.");
} }
return currency; return currency;
@@ -58,7 +58,7 @@ export function createSale({
saleDate, saleDate,
saleLocation, saleLocation,
soldPriceMinor, soldPriceMinor,
currency = 'USD', currency = "USD",
platformFeesMinor = 0, platformFeesMinor = 0,
paymentProcessingFeesMinor = 0, paymentProcessingFeesMinor = 0,
shippingCostMinor = 0, shippingCostMinor = 0,
@@ -69,42 +69,55 @@ export function createSale({
notes, notes,
}) { }) {
return { return {
sellerId: requiredText(sellerId, 'Seller ID'), sellerId: requiredText(sellerId, "Seller ID"),
saleDate: requireIsoDate(saleDate, 'Sale date'), saleDate: requireIsoDate(saleDate, "Sale date"),
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0 ...(typeof saleLocation === "string" && saleLocation.trim().length > 0
? { saleLocation: saleLocation.trim() } ? { saleLocation: saleLocation.trim() }
: {}), : {}),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
currency: requireCurrency(currency), currency: requireCurrency(currency),
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'), platformFeesMinor: requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
),
paymentProcessingFeesMinor: requireNonNegativeMinorUnit( paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
paymentProcessingFeesMinor, paymentProcessingFeesMinor,
'Payment processing fees', "Payment processing fees",
), ),
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'), shippingCostMinor: requireNonNegativeMinorUnit(
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'), shippingCostMinor,
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'), "Shipping cost",
...(typeof buyerName === 'string' && buyerName.trim().length > 0 ),
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
...(typeof buyerName === "string" && buyerName.trim().length > 0
? { buyerName: buyerName.trim() } ? { buyerName: buyerName.trim() }
: {}), : {}),
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0 ...(typeof buyerContact === "string" && buyerContact.trim().length > 0
? { buyerContact: buyerContact.trim() } ? { buyerContact: buyerContact.trim() }
: {}), : {}),
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}), ...(typeof notes === "string" && notes.trim().length > 0
? { notes: notes.trim() }
: {}),
}; };
} }
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) { export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
return { return {
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'), costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
}; };
} }
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) { export function createSaleItem({
saleId,
inventoryItemId,
soldPriceMinor,
costBasisMinor,
}) {
return { return {
saleId: requiredText(saleId, 'Sale ID'), saleId: requiredText(saleId, "Sale ID"),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'), inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
...validateSaleItem({ soldPriceMinor, costBasisMinor }), ...validateSaleItem({ soldPriceMinor, costBasisMinor }),
}; };
} }
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
taxesMinor = 0, taxesMinor = 0,
otherFeesMinor = 0, otherFeesMinor = 0,
}) { }) {
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price'); const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'); const platformFees = requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
);
const processingFees = requireNonNegativeMinorUnit( const processingFees = requireNonNegativeMinorUnit(
paymentProcessingFeesMinor, paymentProcessingFeesMinor,
'Payment processing fees', "Payment processing fees",
); );
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'); const shipping = requireNonNegativeMinorUnit(
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes'); shippingCostMinor,
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'); "Shipping cost",
);
const taxes = requireNonNegativeMinorUnit(taxesMinor, "Taxes");
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, "Other fees");
return salePrice - platformFees - processingFees - shipping - taxes - otherFees; return (
salePrice - platformFees - processingFees - shipping - taxes - otherFees
);
} }
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray } from "drizzle-orm";
import { import {
inventoryItems, inventoryItems,
@@ -6,14 +6,20 @@ import {
saleItems, saleItems,
sales, sales,
securityAuditEvents, securityAuditEvents,
} from '../../../infrastructure/database/schema.js'; } from "../../../infrastructure/database/schema.js";
export function createSaleRepository(db) { export function createSaleRepository(db) {
return { return {
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) { async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
return this.createForInventoryItems({ return this.createForInventoryItems({
sale, sale,
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }], items: [
{
inventoryItemId,
soldPriceMinor: sale.soldPriceMinor,
costBasisMinor,
},
],
}); });
}, },
@@ -23,33 +29,57 @@ export function createSaleRepository(db) {
.select({ id: inventoryItems.id, status: inventoryItems.status }) .select({ id: inventoryItems.id, status: inventoryItems.status })
.from(inventoryItems) .from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and( .where(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)), and(
eq(products.sellerId, sale.sellerId), inArray(
)); inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(products.sellerId, sale.sellerId),
),
);
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' }; if (ownedInventoryItems.length !== items.length)
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE'); return { status: "NOT_FOUND" };
const unavailableItem = ownedInventoryItems.find(
(item) => item.status !== "ACTIVE",
);
if (unavailableItem) { if (unavailableItem) {
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status }; return {
status: "NOT_ACTIVE",
inventoryStatus: unavailableItem.status,
};
} }
const [createdSale] = await transaction.insert(sales).values(sale).returning(); const [createdSale] = await transaction
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({ .insert(sales)
saleId: createdSale.id, .values(sale)
...item, .returning();
}))).returning(); const createdSaleItems = await transaction
.insert(saleItems)
.values(
items.map((item) => ({
saleId: createdSale.id,
...item,
})),
)
.returning();
const updatedInventoryItems = await transaction const updatedInventoryItems = await transaction
.update(inventoryItems) .update(inventoryItems)
.set({ status: 'SOLD', updatedAt: new Date() }) .set({ status: "SOLD", updatedAt: new Date() })
.where(and( .where(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)), and(
eq(inventoryItems.status, 'ACTIVE'), inArray(
)) inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(inventoryItems.status, "ACTIVE"),
),
)
.returning(); .returning();
return { return {
status: 'CREATED', status: "CREATED",
sale: createdSale, sale: createdSale,
saleItems: createdSaleItems, saleItems: createdSaleItems,
saleItem: createdSaleItems[0], saleItem: createdSaleItems[0],
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
}) })
.from(sales) .from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id)) .innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(sales.sellerId, sellerId)) .where(eq(sales.sellerId, sellerId))
.orderBy(desc(sales.saleDate), desc(sales.createdAt)); .orderBy(desc(sales.saleDate), desc(sales.createdAt));
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
productNames: [], productNames: [],
}; };
current.saleItems.push(row.saleItem); current.saleItems.push(row.saleItem);
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus }); current.inventoryItems.push({
id: row.saleItem.inventoryItemId,
status: row.inventoryStatus,
});
current.productNames.push(row.productName); current.productNames.push(row.productName);
grouped.set(row.sale.id, current); grouped.set(row.sale.id, current);
} }
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
...sale, ...sale,
saleItem: sale.saleItems[0], saleItem: sale.saleItems[0],
inventoryItem: sale.inventoryItems[0], inventoryItem: sale.inventoryItems[0],
productName: sale.productNames.join(', '), productName: sale.productNames.join(", "),
})); }));
}, },
async voidForSeller({ saleId, sellerId, actorUserId, reason }) { async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const saleRecords = await transaction const saleRecords = await transaction
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status }) .select({
sale: sales,
saleItem: saleItems,
inventoryStatus: inventoryItems.status,
})
.from(sales) .from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id)) .innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId)) .innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId))); .where(
and(
eq(sales.id, saleId),
eq(sales.sellerId, sellerId),
eq(products.sellerId, sellerId),
),
);
if (!saleRecords.length) return { status: 'NOT_FOUND' }; if (!saleRecords.length) return { status: "NOT_FOUND" };
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' }; if (saleRecords[0].sale.status === "VOIDED")
return { status: "ALREADY_VOIDED" };
const saleRecord = saleRecords[0]; const saleRecord = saleRecords[0];
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId); const inventoryItemIds = saleRecords.map(
({ saleItem }) => saleItem.inventoryItemId,
);
const [updatedSale] = await transaction const [updatedSale] = await transaction
.update(sales) .update(sales)
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() }) .set({
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED'))) status: "VOIDED",
voidReason: reason,
voidedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
.returning(); .returning();
const [updatedInventoryItem] = await transaction const [updatedInventoryItem] = await transaction
.update(inventoryItems) .update(inventoryItems)
.set({ status: 'ACTIVE', updatedAt: new Date() }) .set({ status: "ACTIVE", updatedAt: new Date() })
.where(and( .where(
inArray(inventoryItems.id, inventoryItemIds), and(
eq(inventoryItems.status, 'SOLD'), inArray(inventoryItems.id, inventoryItemIds),
)) eq(inventoryItems.status, "SOLD"),
),
)
.returning(); .returning();
await transaction.insert(securityAuditEvents).values({ await transaction.insert(securityAuditEvents).values({
userId: actorUserId, userId: actorUserId,
event: 'SALE_VOIDED', event: "SALE_VOIDED",
metadata: { saleId, inventoryItemIds, reason }, metadata: { saleId, inventoryItemIds, reason },
}); });
return { return {
status: 'VOIDED', status: "VOIDED",
sale: updatedSale, sale: updatedSale,
saleItems: saleRecords.map(({ saleItem }) => saleItem), saleItems: saleRecords.map(({ saleItem }) => saleItem),
saleItem: saleRecord.saleItem, saleItem: saleRecord.saleItem,
@@ -139,4 +198,4 @@ export function createSaleRepository(db) {
}); });
}, },
}; };
} }
@@ -1,47 +1,64 @@
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale'; import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale'; import {
createMultiItemSaleSchema,
createSaleSchema,
voidSaleSchema,
} from "@pokemon-inventory/validation/sale";
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) { function validationError(reply, result) {
return reply.code(400).send({ return reply.code(400).send({
error: { error: {
code: 'VALIDATION_ERROR', code: "VALIDATION_ERROR",
message: 'Request validation failed.', message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({ message, path })), details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
}, },
}); });
} }
async function defaultAuthenticateAdmin() { async function defaultAuthenticateAdmin() {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
} }
export async function registerAdminSaleRoutes(app, options) { export async function registerAdminSaleRoutes(app, options) {
const { const {
authenticateAdmin = defaultAuthenticateAdmin, authenticateAdmin = defaultAuthenticateAdmin,
authorizeUnsafeRequest, authorizeUnsafeRequest,
csrfCookieName = 'pokemon_inventory_csrf', csrfCookieName = "pokemon_inventory_csrf",
sessionCookieName = 'pokemon_inventory_session', sessionCookieName = "pokemon_inventory_session",
saleService, saleService,
} = options; } = options;
app.addHook('preHandler', async (request) => { app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request); const admin = await authenticateAdmin(request);
if (!admin?.sellerId) { if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
} }
request.admin = admin; request.admin = admin;
}); });
app.post('/sales', async (request, reply) => { app.post("/sales", async (request, reply) => {
await authorizeUnsafeRequest?.( await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName], request.cookies[sessionCookieName],
request.headers['x-csrf-token'], request.headers["x-csrf-token"],
); );
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema; const schema = Array.isArray(request.body?.items)
? createMultiItemSaleSchema
: createSaleSchema;
const result = schema.safeParse(request.body); const result = schema.safeParse(request.body);
if (!result.success) { if (!result.success) {
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
return reply.code(201).send({ data: toAdminSaleDto(sale) }); return reply.code(201).send({ data: toAdminSaleDto(sale) });
}); });
app.get('/sales', async (request) => ({ app.get("/sales", async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto), data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
toAdminSaleDto,
),
})); }));
app.post('/sales/:id/void', async (request, reply) => { app.post("/sales/:id/void", async (request, reply) => {
await authorizeUnsafeRequest?.( await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName], request.cookies[sessionCookieName],
request.headers['x-csrf-token'], request.headers["x-csrf-token"],
); );
const result = voidSaleSchema.safeParse(request.body); const result = voidSaleSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result); if (!result.success) return validationError(reply, result);
@@ -76,4 +95,4 @@ export async function registerAdminSaleRoutes(app, options) {
}); });
return reply.code(200).send({ data: toAdminSaleDto(sale) }); return reply.code(200).send({ data: toAdminSaleDto(sale) });
}); });
} }
@@ -1,35 +1,62 @@
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js'; import {
import { createApplicationError } from '../../../shared/errors/application-error.js'; calculateNetSaleProceeds,
createSale,
validateSaleItem,
} from "../domain/sale.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createSaleService({ saleRepository }) { export function createSaleService({ saleRepository }) {
return { return {
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) { async create({
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }]; sellerId,
inventoryItemId,
costBasisMinor,
items,
...input
}) {
const saleItems = items ?? [
{
inventoryItemId,
soldPriceMinor: input.soldPriceMinor,
costBasisMinor,
},
];
const totalSoldPriceMinor = items const totalSoldPriceMinor = items
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0) ? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
: input.soldPriceMinor; : input.soldPriceMinor;
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor }); const sale = createSale({
sellerId,
...input,
soldPriceMinor: totalSoldPriceMinor,
});
const validatedItems = saleItems.map((item) => ({ const validatedItems = saleItems.map((item) => ({
inventoryItemId: item.inventoryItemId, inventoryItemId: item.inventoryItemId,
soldPriceMinor: validateSaleItem(item).soldPriceMinor, soldPriceMinor: validateSaleItem(item).soldPriceMinor,
costBasisMinor: validateSaleItem(item).costBasisMinor, costBasisMinor: validateSaleItem(item).costBasisMinor,
})); }));
const result = items const result = items
? await saleRepository.createForInventoryItems({ sale, items: validatedItems }) ? await saleRepository.createForInventoryItems({
sale,
items: validatedItems,
})
: await saleRepository.createForInventoryItem({ : await saleRepository.createForInventoryItem({
sale, sale,
inventoryItemId, inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor, costBasisMinor: validatedItems[0].costBasisMinor,
}); });
if (result.status === 'NOT_FOUND') { if (result.status === "NOT_FOUND") {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
} }
if (result.status === 'NOT_ACTIVE') { if (result.status === "NOT_ACTIVE") {
throw createApplicationError( throw createApplicationError(
'INVENTORY_NOT_AVAILABLE_FOR_SALE', "INVENTORY_NOT_AVAILABLE_FOR_SALE",
'Inventory item is not available for sale.', "Inventory item is not available for sale.",
409, 409,
); );
} }
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
}, },
async void({ saleId, sellerId, actorUserId, reason }) { async void({ saleId, sellerId, actorUserId, reason }) {
if (typeof reason !== 'string' || reason.trim().length < 3) { if (typeof reason !== "string" || reason.trim().length < 3) {
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400); throw createApplicationError(
"SALE_VOID_REASON_REQUIRED",
"A void reason is required.",
400,
);
} }
const result = await saleRepository.voidForSeller({ const result = await saleRepository.voidForSeller({
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
actorUserId, actorUserId,
reason: reason.trim(), reason: reason.trim(),
}); });
if (result.status === 'NOT_FOUND') { if (result.status === "NOT_FOUND") {
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404); throw createApplicationError(
"SALE_NOT_FOUND",
"Sale was not found.",
404,
);
} }
if (result.status === 'ALREADY_VOIDED') { if (result.status === "ALREADY_VOIDED") {
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409); throw createApplicationError(
"SALE_ALREADY_VOIDED",
"Sale is already voided.",
409,
);
} }
return withFinancialSummary(result); return withFinancialSummary(result);
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
} }
function withFinancialSummary(result) { function withFinancialSummary(result) {
const netProceedsMinor = calculateNetSaleProceeds(result.sale); const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const saleItems = result.saleItems ?? [result.saleItem]; const saleItems = result.saleItems ?? [result.saleItem];
return { return {
...result, ...result,
netProceedsMinor, netProceedsMinor,
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0), profitMinor:
netProceedsMinor -
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
}; };
} }
@@ -1,5 +1,5 @@
function requiredString(value, fieldName) { function requiredString(value, fieldName) {
if (typeof value !== 'string') { if (typeof value !== "string") {
throw new Error(`${fieldName} must be a string.`); throw new Error(`${fieldName} must be a string.`);
} }
@@ -7,71 +7,105 @@ function requiredString(value, fieldName) {
} }
export function normalizeSearchQuery(value) { export function normalizeSearchQuery(value) {
return requiredString(value, 'Search query') return requiredString(value, "Search query")
.trim() .trim()
.normalize('NFKC') .normalize("NFKC")
.replace(/[^\p{L}\p{N}\s]+/gu, ' ') .replace(/[^\p{L}\p{N}\s]+/gu, " ")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.toLowerCase(); .toLowerCase();
} }
export function createSearchQuery({ export function createSearchQuery({
query = '', query = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
limit = 20, limit = 20,
includeSold = false, includeSold = false,
sort = 'recent', sort = "recent",
}) { }) {
const text = normalizeSearchQuery(query); const text = normalizeSearchQuery(query);
if (!Number.isInteger(page) || page < 1) { if (!Number.isInteger(page) || page < 1) {
throw new Error('page must be >= 1'); throw new Error("page must be >= 1");
} }
if (!Number.isInteger(limit) || limit < 1 || limit > 100) { if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('limit must be between 1 and 100'); throw new Error("limit must be between 1 and 100");
} }
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) { if (status && !["AVAILABLE", "PENDING", "SOLD", "ALL"].includes(status)) {
throw new Error('minPriceMinor must be a non-negative integer'); throw new Error("status must be AVAILABLE, PENDING, SOLD, or ALL");
}
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
throw new Error('maxPriceMinor must be a non-negative integer');
} }
if ( if (
typeof minPriceMinor !== 'undefined' && typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
minPriceMinor > maxPriceMinor
) { ) {
throw new Error('minPriceMinor must be <= maxPriceMinor'); throw new Error("minPriceMinor must be a non-negative integer");
} }
if (!['recent', 'price_low', 'price_high'].includes(sort)) { if (
throw new Error('sort must be recent, price_low, or price_high'); typeof maxPriceMinor !== "undefined" &&
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
) {
throw new Error("maxPriceMinor must be a non-negative integer");
}
if (
typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== "undefined" &&
minPriceMinor > maxPriceMinor
) {
throw new Error("minPriceMinor must be <= maxPriceMinor");
}
if (!["recent", "updated", "name_asc", "name_desc", "price_low", "price_high", "market_low", "market_high"].includes(sort)) {
throw new Error("sort must be recent, updated, name_asc, name_desc, price_low, price_high, market_low, or market_high");
} }
return { return {
text, text,
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}), ...(typeof category === "string" && category.trim().length > 0
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}), ? { category: category.trim() }
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}), : {}),
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}), ...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() }
: {}),
...(typeof setName === "string" && setName.trim().length > 0
? { setName: setName.trim() }
: {}),
...(typeof eventName === "string" && eventName.trim().length > 0
? { eventName: eventName.trim() }
: {}),
...(typeof tag === "string" && tag.trim().length > 0
? { tag: tag.trim().toLowerCase() }
: {}),
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page, page,
limit, limit,
includeSold, includeSold,
...(sort !== 'recent' ? { sort } : {}), ...(status ? { status } : {}),
...(newlyAdded ? { newlyAdded: true } : {}),
...(sort !== "recent" ? { sort } : {}),
}; };
} }
export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) { export function buildSearchFilters({
includeSold = false,
status = "AVAILABLE",
sort = ["recently_added"],
}) {
return { return {
status: includeSold ? 'ALL' : status, status: includeSold ? "ALL" : status,
includeSold, includeSold,
sort, sort,
}; };
@@ -0,0 +1,32 @@
import { and, asc, eq } from 'drizzle-orm';
import { savedViews } from '../../../infrastructure/database/schema.js';
export function createSavedViewRepository(db) {
return {
async listBySellerId(sellerId) {
return db.select().from(savedViews).where(eq(savedViews.sellerId, sellerId)).orderBy(asc(savedViews.name));
},
async create(sellerId, view) {
const [created] = await db.insert(savedViews).values({ sellerId, ...view }).returning();
return created;
},
async updateForSeller(id, sellerId, view) {
const [updated] = await db.update(savedViews).set({ ...view, updatedAt: new Date() }).where(and(
eq(savedViews.id, id),
eq(savedViews.sellerId, sellerId),
)).returning();
return updated ?? null;
},
async deleteForSeller(id, sellerId) {
const [deleted] = await db.delete(savedViews).where(and(
eq(savedViews.id, id),
eq(savedViews.sellerId, sellerId),
)).returning();
return deleted ?? null;
},
};
}
@@ -0,0 +1,44 @@
import { savedViewSchema } from '@pokemon-inventory/validation/saved-view';
import { createApplicationError } from '../../../shared/errors/application-error.js';
export async function registerAdminSavedViewRoutes(app, options) {
const {
authenticateAdmin = async () => {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
savedViewService,
} = options;
app.addHook('preHandler', async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
request.admin = admin;
});
app.get('/saved-views', async (request) => ({
data: await savedViewService.list({ sellerId: request.admin.sellerId }),
}));
app.post('/saved-views', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const result = savedViewSchema.safeParse(request.body);
if (!result.success) return reply.code(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.' } });
return reply.code(201).send({ data: await savedViewService.create({ sellerId: request.admin.sellerId, view: result.data }) });
});
app.patch('/saved-views/:id', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const result = savedViewSchema.safeParse(request.body);
if (!result.success) return reply.code(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.' } });
return reply.send({ data: await savedViewService.update({ sellerId: request.admin.sellerId, viewId: request.params.id, view: result.data }) });
});
app.delete('/saved-views/:id', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
await savedViewService.delete({ sellerId: request.admin.sellerId, viewId: request.params.id });
return reply.code(204).send();
});
}
@@ -1,13 +1,17 @@
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from "../../../shared/errors/application-error.js";
function coerceNumber(value, fieldName, fallback) { function coerceNumber(value, fieldName, fallback) {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === "") {
return fallback; return fallback;
} }
const parsed = Number.parseInt(String(value), 10); const parsed = Number.parseInt(String(value), 10);
if (Number.isNaN(parsed) || String(parsed) !== String(value)) { if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400); throw createApplicationError(
"INVALID_SEARCH_FILTER",
`${fieldName} must be an integer.`,
400,
);
} }
return parsed; return parsed;
} }
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
export async function registerPublicSearchRoutes(app, options) { export async function registerPublicSearchRoutes(app, options) {
const { searchService } = options; const { searchService } = options;
app.get('/public/search', async (request) => { app.get("/public/search", async (request) => {
if (!searchService || typeof searchService.searchPublic !== 'function') { if (!searchService || typeof searchService.searchPublic !== "function") {
throw createApplicationError( throw createApplicationError(
'SEARCH_SERVICE_UNAVAILABLE', "SEARCH_SERVICE_UNAVAILABLE",
'Public search is currently unavailable.', "Public search is currently unavailable.",
503, 503,
); );
} }
@@ -28,14 +32,29 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query, query: request.query.query,
category: request.query.category, category: request.query.category,
conditionId: request.query.conditionId, conditionId: request.query.conditionId,
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined), setName: request.query.setName,
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined), eventName: request.query.eventName,
page: coerceNumber(request.query.page, 'page', 1), tag: request.query.tag,
limit: coerceNumber(request.query.limit, 'limit', 20), status: request.query.status,
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true, newlyAdded: request.query.newlyAdded === "true" || request.query.newlyAdded === 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) }; return { data: await searchService.searchPublic(filters) };
}); });
} }
@@ -0,0 +1,27 @@
import { savedViewSchema } from '@pokemon-inventory/validation/saved-view';
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createSavedViewService({ savedViewRepository }) {
return {
async list({ sellerId }) {
return savedViewRepository.listBySellerId(sellerId);
},
async create({ sellerId, view }) {
return savedViewRepository.create(sellerId, savedViewSchema.parse(view));
},
async update({ sellerId, viewId, view }) {
const updated = await savedViewRepository.updateForSeller(viewId, sellerId, savedViewSchema.parse(view));
if (!updated) throw createApplicationError('SAVED_VIEW_NOT_FOUND', 'Saved view was not found.', 404);
return updated;
},
async delete({ sellerId, viewId }) {
const deleted = await savedViewRepository.deleteForSeller(viewId, sellerId);
if (!deleted) throw createApplicationError('SAVED_VIEW_NOT_FOUND', 'Saved view was not found.', 404);
return deleted;
},
};
}
@@ -0,0 +1,24 @@
import { eq } from 'drizzle-orm';
import { sellerSettings } from '../../../infrastructure/database/schema.js';
export function createSellerSettingsRepository(db) {
return {
async findBySellerId(sellerId) {
const [record] = await db.select().from(sellerSettings).where(eq(sellerSettings.sellerId, sellerId));
return record ?? null;
},
async upsert(sellerId, settings) {
const [record] = await db
.insert(sellerSettings)
.values({ sellerId, settings })
.onConflictDoUpdate({
target: sellerSettings.sellerId,
set: { settings, updatedAt: new Date() },
})
.returning();
return record;
},
};
}
@@ -0,0 +1,50 @@
import { sellerSettingsSchema } from '@pokemon-inventory/validation/settings';
import { createApplicationError } from '../../../shared/errors/application-error.js';
export async function registerAdminSettingsRoutes(app, options) {
const {
authenticateAdmin = async () => {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sellerSettingsService,
} = options;
app.addHook('preHandler', async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
}
request.admin = admin;
});
app.get('/settings', async (request) => ({
data: await sellerSettingsService.get({ sellerId: request.admin.sellerId }),
}));
app.patch('/settings', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const result = sellerSettingsSchema.safeParse(request.body);
if (!result.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
},
});
}
return reply.send({
data: await sellerSettingsService.update({
sellerId: request.admin.sellerId,
settings: result.data,
}),
});
});
}
@@ -0,0 +1,27 @@
import { sellerSettingsSchema } from '@pokemon-inventory/validation/settings';
export function createSellerSettingsService({ sellerSettingsRepository }) {
return {
async get({ sellerId }) {
const record = await sellerSettingsRepository.findBySellerId(sellerId);
return record?.settings ?? {};
},
async update({ sellerId, settings }) {
const validated = sellerSettingsSchema.parse(settings);
const current = await this.get({ sellerId });
const merged = mergeSettings(current, validated);
const record = await sellerSettingsRepository.upsert(sellerId, merged);
return record.settings;
},
};
}
function mergeSettings(current, update) {
return Object.fromEntries(
Object.entries({ ...current, ...update }).map(([category, values]) => [
category,
{ ...(current[category] ?? {}), ...values },
]),
);
}
@@ -1,23 +1,26 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js'; import { createAcquisitionService } from "../../src/modules/acquisitions/services/acquisition-service.js";
test('creates an owned acquisition and calculates cost basis', async () => { test("creates an owned acquisition and calculates cost basis", async () => {
let received; let received;
const service = createAcquisitionService({ const service = createAcquisitionService({
acquisitionRepository: { acquisitionRepository: {
async createForSeller(input) { async createForSeller(input) {
received = input; received = input;
return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } }; return {
status: "CREATED",
acquisition: { id: "acquisition-1", ...input.acquisition },
};
}, },
}, },
}); });
const result = await service.create({ const result = await service.create({
sellerId: 'seller-1', sellerId: "seller-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
shippingMinor: 150, shippingMinor: 150,
taxMinor: 75, taxMinor: 75,
@@ -25,17 +28,27 @@ test('creates an owned acquisition and calculates cost basis', async () => {
estimatedTradeValueMinor: 200, estimatedTradeValueMinor: 200,
}); });
assert.equal(received.sellerId, 'seller-1'); assert.equal(received.sellerId, "seller-1");
assert.equal(result.costBasisMinor, 1075); assert.equal(result.costBasisMinor, 1075);
}); });
test('rejects acquisition for an inventory item outside the seller account', async () => { test("rejects acquisition for an inventory item outside the seller account", async () => {
const service = createAcquisitionService({ const service = createAcquisitionService({
acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } }, acquisitionRepository: {
async createForSeller() {
return { status: "NOT_FOUND" };
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }), () =>
(error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404, service.create({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
method: "PURCHASE",
costMinor: 100,
}),
(error) => error.code === "INVENTORY_NOT_FOUND" && error.statusCode === 404,
); );
}); });
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js';
test('exports seller inventory through the admin DTO boundary', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1');
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
createdAt: new Date('2026-09-10T00:00:00.000Z'),
updatedAt: new Date('2026-09-10T00:00:00.000Z'),
}];
},
},
});
assert.deepEqual(await service.inventoryJson({ sellerId: 'seller-1' }), [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
createdAt: '2026-09-10T00:00:00.000Z',
updatedAt: '2026-09-10T00:00:00.000Z',
}]);
});
test('exports inventory as escaped CSV rows', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId() {
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Front, "clean"\nback',
}];
},
},
});
assert.equal(await service.inventoryCsv({ sellerId: 'seller-1' }), [
'id,productId,conditionId,status,notes,createdAt,updatedAt',
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
].join('\r\n'));
});
test('exports inventory as an escaped Excel XML workbook', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId() {
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE', notes: 'A <note>' }];
},
},
});
const workbook = await service.inventoryExcel({ sellerId: 'seller-1' });
assert.match(workbook, /Excel\.Sheet/);
assert.match(workbook, /A &lt;note&gt;/);
assert.match(workbook, /<Cell ss:StyleID="Header"><Data ss:Type="String">id<\/Data><\/Cell>/);
});
@@ -1,20 +1,59 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js'; import { createDashboardService } from "../../src/modules/dashboard/services/dashboard-service.js";
test('aggregates seller dashboard counts and financial totals', async () => { test("aggregates seller dashboard counts and financial totals", async () => {
const service = createDashboardService({ const service = createDashboardService({
productService: { async listBySellerId() { return [{ id: 'product-1' }]; } }, productService: {
inventoryItemService: { async listBySellerId() { return [{ id: 'item-1', status: 'ACTIVE' }, { id: 'item-2', status: 'SOLD' }]; } }, async listBySellerId() {
listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } }, return [{ id: "product-1" }];
saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } }, },
acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } }, },
pricingRepository: { async listLatestValuationsForSeller() { return [{ inventoryItemId: 'item-1', valueMinor: 2800, calculatedAt: new Date() }]; } }, inventoryItemService: {
pricingRefreshJobRepository: { async countFailedForSeller() { return 2; } }, async listBySellerId() {
return [
{ id: "item-1", status: "ACTIVE" },
{ id: "item-2", status: "SOLD" },
];
},
},
listingService: {
async list() {
return [{ status: "AVAILABLE", askingPriceMinor: 2500, imageCount: 1 }];
},
},
saleService: {
async list() {
return [
{ status: "COMPLETED", netProceedsMinor: 2200, profitMinor: 700 },
];
},
},
acquisitionService: {
async list() {
return [{ costBasisMinor: 1500 }];
},
},
pricingRepository: {
async listLatestValuationsForSeller() {
return [
{
inventoryItemId: "item-1",
valueMinor: 2800,
calculatedAt: new Date(),
},
];
},
},
pricingRefreshJobRepository: {
async countFailedForSeller() {
return 2;
},
},
}); });
const dashboard = await service.get({ sellerId: 'seller-1' }); const dashboard = await service.get({ sellerId: "seller-1" });
assert.deepEqual(dashboard.counts, { assert.deepEqual(dashboard.counts, {
products: 1, products: 1,
inventory: 2, inventory: 2,
@@ -35,4 +74,4 @@ test('aggregates seller dashboard counts and financial totals', async () => {
totalProfitMinor: 700, totalProfitMinor: 700,
totalMarketValueMinor: 2800, totalMarketValueMinor: 2800,
}); });
}); });
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { createSharpImageInspector } from '../../src/infrastructure/images/sharp-image-inspector.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
function createService() {
const writes = [];
return { writes, service: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: { async put(input) { writes.push(input); }, async delete() {} },
inventoryImageRepository: { async createForSeller(input) { return { id: 'image-1', ...input }; } },
}) };
}
const metadata = { inventoryItemId: 'item-1', sellerId: 'seller-1', storageKey: 'test', originalFilename: 'test.png', width: 9999, height: 9999, fileSize: 1 };
test('inspects JPEG, PNG and WebP bytes and stores their true dimensions and size', async () => {
for (const [format, mimeType] of [['jpeg', 'image/jpeg'], ['png', 'image/png'], ['webp', 'image/webp']]) {
const data = await sharp({ create: { width: 12, height: 8, channels: 3, background: '#123456' } }).toFormat(format).toBuffer();
const { service, writes } = createService();
const image = await service.upload({ ...metadata, data, mimeType });
assert.equal(image.width, 12);
assert.equal(image.height, 8);
assert.equal(image.fileSize, data.length);
assert.equal(writes.length, 1);
}
});
test('rejects actual oversized dimensions, signature-only and truncated images before storage', async () => {
const oversized = await sharp({ create: { width: 10001, height: 1, channels: 3, background: 'white' } }).png().toBuffer();
const valid = await sharp({ create: { width: 12, height: 8, channels: 3, background: 'white' } }).png().toBuffer();
const incompletePixels = valid.subarray(0, valid.length - 15);
assert.equal((await createSharpImageInspector().readMetadata(incompletePixels)).width, 12);
for (const [data, code] of [[incompletePixels, 'IMAGE_DECODE_INVALID'], [oversized, 'IMAGE_DIMENSIONS_INVALID'], [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'IMAGE_DECODE_INVALID'], [valid.subarray(0, 40), 'IMAGE_DECODE_INVALID']]) {
const { service, writes } = createService();
await assert.rejects(() => service.upload({ ...metadata, width: 1, height: 1, data, mimeType: 'image/png' }), { code });
assert.deepEqual(writes, []);
}
});
test('checks true byte length before image inspection', async () => {
const data = Buffer.alloc(10 * 1024 * 1024 + 1);
Buffer.from([0xff, 0xd8, 0xff]).copy(data);
const service = createInventoryImageService({ imageInspector: { async readMetadata() { assert.fail('oversized input decoded'); } } });
await assert.rejects(() => service.upload({ ...metadata, data, mimeType: 'image/jpeg' }), { code: 'IMAGE_FILE_TOO_LARGE' });
});
@@ -35,3 +35,32 @@ test('manages owned image order, primary state, alt text, retry, and deletion',
assert.ok(calls.some(([action]) => action === 'delete-job')); assert.ok(calls.some(([action]) => action === 'delete-job'));
assert.ok(calls.some(([action]) => action === 'storage-delete')); assert.ok(calls.some(([action]) => action === 'storage-delete'));
}); });
test('moves an image through the owned atomic repository operation', async () => {
const service = createInventoryImageService({ inventoryImageRepository: {
async moveForSeller(id, direction, sellerId) {
assert.equal(direction, 'down');
assert.equal(sellerId, 'seller-1');
return id === 'owned' ? { id, sortOrder: 1 } : null;
},
} });
assert.deepEqual(await service.move({ imageId: 'owned', direction: 'down', sellerId: 'seller-1' }), { id: 'owned', sortOrder: 1 });
await assert.rejects(() => service.move({ imageId: 'missing', direction: 'down', sellerId: 'seller-1' }), { code: 'IMAGE_NOT_FOUND' });
await assert.rejects(() => service.move({ imageId: 'owned', direction: 'left', sellerId: 'seller-1' }), { code: 'IMAGE_MOVE_INVALID' });
});
test('validates image updates before persistence and normalizes valid alt text', async () => {
const { service, calls } = createService();
for (const sortOrder of [undefined, null, -1, 0.5, '1', NaN, Infinity, 2147483648]) {
await assert.rejects(() => service.updateOrder({ imageId: 'image-1', sortOrder, sellerId: 'seller-1' }), { code: 'IMAGE_ORDER_INVALID' });
}
for (const altText of [undefined, 123, {}, [], 'x'.repeat(1001)]) {
await assert.rejects(() => service.updateAltText({ imageId: 'image-1', altText, sellerId: 'seller-1' }), { code: 'IMAGE_ALT_TEXT_INVALID' });
}
assert.deepEqual(calls, []);
await service.updateOrder({ imageId: 'image-1', sortOrder: 2147483647, sellerId: 'seller-1' });
for (const [input, expected] of [[' Front ', 'Front'], [' ', ''], [null, null], ['x'.repeat(1000), 'x'.repeat(1000)]]) {
const result = await service.updateAltText({ imageId: 'image-1', altText: input, sellerId: 'seller-1' });
assert.equal(result.altText, expected);
}
});
@@ -19,6 +19,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
const stored = []; const stored = [];
const jobs = []; const jobs = [];
const service = createInventoryImageService({ const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: { imageStorage: {
async put(value) { stored.push(value); }, async put(value) { stored.push(value); },
async delete() {}, async delete() {},
@@ -38,6 +39,9 @@ test('stores validated upload bytes before persisting image metadata', async ()
assert.equal(stored.length, 1); assert.equal(stored.length, 1);
assert.deepEqual(stored[0], { key: input.storageKey, data: input.data }); assert.deepEqual(stored[0], { key: input.storageKey, data: input.data });
assert.equal(image.id, 'image-1'); assert.equal(image.id, 'image-1');
assert.equal(image.width, 2);
assert.equal(image.height, 3);
assert.equal(image.fileSize, input.data.length);
assert.equal(image.processingStatus, 'PENDING'); assert.equal(image.processingStatus, 'PENDING');
assert.equal('data' in image, false); assert.equal('data' in image, false);
assert.deepEqual(jobs, [{ assert.deepEqual(jobs, [{
@@ -50,6 +54,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
test('removes stored bytes when metadata persistence fails', async () => { test('removes stored bytes when metadata persistence fails', async () => {
const deleted = []; const deleted = [];
const service = createInventoryImageService({ const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: { imageStorage: {
async put() {}, async put() {},
async delete(key) { deleted.push(key); }, async delete(key) { deleted.push(key); },
@@ -64,4 +69,21 @@ test('removes stored bytes when metadata persistence fails', async () => {
(error) => error.code === 'INVENTORY_ITEM_NOT_FOUND', (error) => error.code === 'INVENTORY_ITEM_NOT_FOUND',
); );
assert.deepEqual(deleted, [input.storageKey]); assert.deepEqual(deleted, [input.storageKey]);
}); });
test('passes the seller image limit to the atomic repository operation', async () => {
let limit;
const service = createInventoryImageService({
sellerSettingsRepository: { async findBySellerId() { return { settings: { inventory: { maxImagesPerItem: 2 } } }; } },
inventoryImageRepository: {
async createForSellerWithLimit(image, sellerId, maxImages) {
assert.equal(sellerId, 'seller-id');
limit = maxImages;
return { id: 'image-1', ...image };
},
},
});
const image = await service.create({ ...input, data: undefined });
assert.equal(image.id, 'image-1');
assert.equal(limit, 2);
});
@@ -7,7 +7,13 @@ function createService({ product, condition }) {
return createInventoryItemService({ return createInventoryItemService({
productRepository: { async findById() { return product; } }, productRepository: { async findById() { return product; } },
conditionRepository: { async findById() { return condition; } }, conditionRepository: { async findById() { return condition; } },
inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } }, inventoryItemRepository: {
async create(inventoryItem) { return inventoryItem; },
async listBySellerId() { return []; },
async archiveByIdsForSeller(inventoryItemIds) {
return inventoryItemIds.map((id) => ({ id, status: 'ARCHIVED' }));
},
},
}); });
} }
@@ -31,6 +37,110 @@ test('creates an inventory item when its condition belongs to the product catego
}); });
}); });
test('archives owned active inventory items in bulk', async () => {
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [
{ id: 'item-1', status: 'ACTIVE' },
{ id: 'item-2', status: 'ACTIVE' },
];
},
async archiveByIdsForSeller(ids) {
return ids.map((id) => ({ id, status: 'ARCHIVED' }));
},
},
});
assert.deepEqual(
await service.archiveMany({ inventoryItemIds: ['item-1', 'item-2'], sellerId: 'seller-id' }),
[
{ id: 'item-1', status: 'ARCHIVED' },
{ id: 'item-2', status: 'ARCHIVED' },
],
);
});
test('does not archive sold inventory items', async () => {
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() { return [{ id: 'item-1', status: 'SOLD' }]; },
async archiveByIdsForSeller() { throw new Error('should not update'); },
},
});
await assert.rejects(
() => service.archiveMany({ inventoryItemIds: ['item-1'], sellerId: 'seller-id' }),
/Inventory status cannot transition from SOLD to ARCHIVED/,
);
});
test('enqueues pricing refresh jobs for owned inventory items', async () => {
const enqueued = [];
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE' }];
},
},
pricingService: {
async enqueueRefresh(input) {
enqueued.push(input);
return { id: 'job-1', status: 'PENDING' };
},
},
});
const jobs = await service.refreshPricingMany({
inventoryItemIds: ['item-1'],
sellerId: 'seller-id',
providerId: 'fixture-provider',
});
assert.deepEqual(enqueued, [{
sellerId: 'seller-id',
inventoryItemId: 'item-1',
providerId: 'fixture-provider',
productId: 'product-1',
conditionId: 'condition-1',
}]);
assert.deepEqual(jobs, [{ id: 'job-1', status: 'PENDING' }]);
});
test('updates Product tags for selected owned inventory items', async () => {
const updates = [];
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [
{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1' },
{ id: 'item-2', productId: 'product-1', conditionId: 'condition-1' },
];
},
async updateProductTagsByIdsForSeller(...input) {
updates.push(input);
return [{ id: 'product-1', tags: ['binder', 'keep'] }];
},
},
});
assert.deepEqual(await service.updateTagsMany({
inventoryItemIds: ['item-1', 'item-2'],
sellerId: 'seller-id',
addTags: ['Binder'],
removeTags: ['sell'],
}), [{ id: 'product-1', tags: ['binder', 'keep'] }]);
assert.deepEqual(updates, [[['item-1', 'item-2'], 'seller-id', ['Binder'], ['sell']]]);
});
test('rejects conditions from another category', async () => { test('rejects conditions from another category', async () => {
const service = createService({ const service = createService({
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' }, product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
@@ -1,38 +1,129 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js'; import {
import { createFixturePricingProvider } from '../../src/modules/pricing/providers/fixture-pricing-provider.js'; createPricingProvider,
createPricingProviderRegistry,
} from "../../src/modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
import {
assertProductionPricingProviderConfig,
createHttpPricingProvider,
} from "../../src/modules/pricing/providers/http-pricing-provider.js";
test('registers pricing providers behind a validated adapter contract', async () => { test("registers pricing providers behind a validated adapter contract", async () => {
const provider = createPricingProvider({ const provider = createPricingProvider({
id: 'example', id: "example",
async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; }, async observe() {
return [{ priceMinor: 2500, sourceIdentifier: "example-1" }];
},
}); });
const registry = createPricingProviderRegistry([provider]); const registry = createPricingProviderRegistry([provider]);
assert.equal(registry.get('example'), provider); assert.equal(registry.get("example"), provider);
assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]); assert.deepEqual(await registry.get("example").observe({}), [
{ priceMinor: 2500, sourceIdentifier: "example-1" },
]);
assert.deepEqual(registry.list(), [provider]); assert.deepEqual(registry.list(), [provider]);
}); });
test('rejects malformed provider adapters and responses', async () => { test("rejects malformed provider adapters and responses", async () => {
assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/); assert.throws(
const provider = createPricingProvider({ id: 'broken', async observe() { return null; } }); () => createPricingProvider({ id: "broken" }),
await assert.rejects(() => provider.observe({}), /must return an observation array/); /observe function is required/,
);
const provider = createPricingProvider({
id: "broken",
async observe() {
return null;
},
});
await assert.rejects(
() => provider.observe({}),
/must return an observation array/,
);
}); });
test('fixture provider returns deterministic normalized observations', async () => { test("fixture provider returns deterministic normalized observations", async () => {
const [observation] = await createFixturePricingProvider().observe({ const [observation] = await createFixturePricingProvider().observe({
productId: 'product-1', inventoryItemId: 'item-1', conditionId: 'condition-1', productId: "product-1",
inventoryItemId: "item-1",
conditionId: "condition-1",
}); });
assert.deepEqual(observation, { assert.deepEqual(observation, {
productId: 'product-1', productId: "product-1",
inventoryItemId: 'item-1', inventoryItemId: "item-1",
conditionId: 'condition-1', conditionId: "condition-1",
priceMinor: 2500, priceMinor: 2500,
currency: 'USD', currency: "USD",
sourceIdentifier: 'fixture-item-1', sourceIdentifier: "fixture-item-1",
qualityScore: 50, qualityScore: 50,
}); });
}); });
test("http provider rejects insecure or malformed external provider URLs", () => {
assert.throws(
() =>
createHttpPricingProvider({
id: "http-provider",
endpoint: "http://provider.example/prices",
}),
/HTTPS/i,
);
assert.throws(
() =>
createHttpPricingProvider({
id: "http-provider",
endpoint: "not-a-url",
}),
/valid https URL/i,
);
});
test("requires a secure external provider in production", () => {
assert.throws(
() => assertProductionPricingProviderConfig({ fixtureEnabled: true, endpoint: "https://provider.example/prices" }),
/fixture pricing provider must be disabled/i,
);
assert.throws(
() => assertProductionPricingProviderConfig({ fixtureEnabled: false, endpoint: "" }),
/endpoint is required/i,
);
assert.throws(
() => assertProductionPricingProviderConfig({ fixtureEnabled: false, endpoint: "http://provider.example/prices" }),
/must use HTTPS/i,
);
assert.doesNotThrow(() => assertProductionPricingProviderConfig({
fixtureEnabled: false,
endpoint: "https://provider.example/prices",
}));
});
test("http provider sends mapping context and normalizes its response", async () => {
let request;
const provider = createHttpPricingProvider({
id: "http-provider",
endpoint: "https://provider.example/prices",
token: "test-token",
async fetchImplementation(url, options) {
request = { url: String(url), options };
return {
ok: true,
async json() {
return { observations: [{ priceMinor: 2500 }] };
},
};
},
});
assert.deepEqual(
await provider.observe({
externalIdentifier: "catalog-1",
searchQuery: "Charizard",
}),
[{ priceMinor: 2500 }],
);
assert.match(request.url, /externalIdentifier=catalog-1/);
assert.equal(request.options.headers.authorization, "Bearer test-token");
});
@@ -1,77 +1,137 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js'; import { createPricingService } from "../../src/modules/pricing/services/pricing-service.js";
test('records an owned pricing observation and calculates a valuation', async () => { test("records an owned pricing observation and calculates a valuation", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: { pricingRepository: {
async addObservationForSeller({ observation, sellerId }) { async addObservationForSeller({ observation, sellerId }) {
assert.equal(sellerId, 'seller-1'); assert.equal(sellerId, "seller-1");
return { status: 'CREATED', observation }; return { status: "CREATED", observation };
}, },
async markOutliersForSeller() { async markOutliersForSeller() {
return { status: 'UPDATED' }; return { status: "UPDATED" };
}, },
async calculateForSeller() { async calculateForSeller() {
return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] }; return {
status: "CALCULATED",
valuation: { valueMinor: 2500 },
observations: [],
};
}, },
}, },
}); });
const observation = await service.addObservation({ const observation = await service.addObservation({
sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1', sellerId: "seller-1",
providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1', productId: "product-1",
inventoryItemId: "inventory-1",
providerId: "manual",
priceMinor: 2500,
sourceIdentifier: "source-1",
conditionId: "condition-1",
}); });
assert.equal(observation.priceMinor, 2500); assert.equal(observation.priceMinor, 2500);
const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' }); const valuation = await service.calculate({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
});
assert.equal(valuation.valuation.valueMinor, 2500); assert.equal(valuation.valuation.valueMinor, 2500);
}); });
test('requires a reason for an audited market-value override', async () => { test("requires a reason for an audited market-value override", async () => {
const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } }); const service = createPricingService({
pricingRepository: {
async createOverride() {
throw new Error("should not run");
},
},
});
await assert.rejects( await assert.rejects(
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }), () =>
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400, service.override({
sellerId: "seller-1",
actorUserId: "user-1",
inventoryItemId: "inventory-1",
valueMinor: 2000,
reason: "x",
}),
(error) =>
error.code === "OVERRIDE_REASON_REQUIRED" && error.statusCode === 400,
); );
}); });
test('fails clearly when a requested pricing provider is unavailable', async () => { test("fails clearly when a requested pricing provider is unavailable", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingProviderRegistry: { get() { return null; } }, pricingProviderRegistry: {
get() {
return null;
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }), () =>
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503, service.refresh({
sellerId: "seller-1",
inventoryItemId: "inventory-1",
providerId: "missing",
}),
(error) =>
error.code === "PRICING_PROVIDER_UNAVAILABLE" && error.statusCode === 503,
); );
}); });
test('enqueues a seller-scoped pricing refresh and reads its status', async () => { test("enqueues a seller-scoped pricing refresh and reads its status", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingRefreshJobRepository: { pricingRefreshJobRepository: {
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; }, async create(input) {
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; }, return { id: "job-1", status: "PENDING", ...input };
},
async findByIdForSeller(id, sellerId) {
return { id, sellerId, status: "COMPLETED" };
},
},
providerMappingRepository: {
async findActiveForSeller() {
return { providerId: "mapped-provider" };
},
}, },
providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } },
}); });
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' }); const job = await service.enqueueRefresh({
assert.equal(job.status, 'PENDING'); sellerId: "seller-1",
assert.equal(job.providerId, 'mapped-provider'); inventoryItemId: "item-1",
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED'); providerId: "provider-1",
productId: "product-1",
conditionId: "condition-1",
});
assert.equal(job.status, "PENDING");
assert.equal(job.providerId, "mapped-provider");
assert.equal(
(await service.getJob({ sellerId: "seller-1", jobId: job.id })).status,
"COMPLETED",
);
}); });
test('lists and retries seller-scoped failed pricing jobs', async () => { test("lists and retries seller-scoped failed pricing jobs", async () => {
const service = createPricingService({ const service = createPricingService({
pricingRepository: {}, pricingRepository: {},
pricingRefreshJobRepository: { pricingRefreshJobRepository: {
async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; }, async listForSeller() {
async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; }, return [{ id: "job-1", status: "FAILED" }];
},
async retryForSeller() {
return { id: "job-1", status: "PENDING" };
},
}, },
}); });
assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1); assert.equal((await service.listJobs({ sellerId: "seller-1" })).length, 1);
assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING'); assert.equal(
}); (await service.retryJob({ sellerId: "seller-1", jobId: "job-1" })).status,
"PENDING",
);
});
@@ -1,21 +1,44 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js'; import { createProviderMappingService } from "../../src/modules/pricing/services/provider-mapping-service.js";
test('creates and disables seller-owned provider mappings', async () => { test("creates and disables seller-owned provider mappings", async () => {
const calls = []; const calls = [];
const service = createProviderMappingService({ const service = createProviderMappingService({
providerMappingRepository: { providerMappingRepository: {
async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; }, async createForSeller(input, sellerId) {
async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; }, calls.push(["create", input, sellerId]);
async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; }, return { status: "CREATED", mapping: { id: "mapping-1", ...input } };
},
async listForSeller() {
return [{ id: "mapping-1", status: "ACTIVE" }];
},
async disableForSeller() {
return {
status: "DISABLED",
mapping: { id: "mapping-1", status: "DISABLED" },
};
},
}, },
}); });
const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }); const created = await service.create({
assert.equal(created.id, 'mapping-1'); sellerId: "seller-1",
assert.equal(calls[0][2], 'seller-1'); productId: "product-1",
assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1); providerId: "fixture",
assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED'); mappingType: "PRODUCT",
externalIdentifier: "ext-1",
});
assert.equal(created.id, "mapping-1");
assert.equal(calls[0][2], "seller-1");
assert.equal(
(await service.list({ sellerId: "seller-1", productId: "product-1" }))
.length,
1,
);
assert.equal(
(await service.disable({ sellerId: "seller-1", id: "mapping-1" })).status,
"DISABLED",
);
}); });
+76 -47
View File
@@ -1,29 +1,29 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { createSaleService } from '../../src/modules/sales/services/sale-service.js'; import { createSaleService } from "../../src/modules/sales/services/sale-service.js";
function validInput(overrides = {}) { function validInput(overrides = {}) {
return { return {
sellerId: 'seller-1', sellerId: "seller-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
costBasisMinor: 1200, costBasisMinor: 1200,
...overrides, ...overrides,
}; };
} }
test('creates a sale through the repository with validated money values', async () => { test("creates a sale through the repository with validated money values", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async createForInventoryItem(input) { async createForInventoryItem(input) {
received = input; received = input;
return { return {
status: 'CREATED', status: "CREATED",
sale: { sale: {
id: 'sale-1', id: "sale-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
platformFeesMinor: 100, platformFeesMinor: 100,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
@@ -38,9 +38,9 @@ test('creates a sale through the repository with validated money values', async
}); });
assert.deepEqual(await service.create(validInput()), { assert.deepEqual(await service.create(validInput()), {
status: 'CREATED', status: "CREATED",
sale: { sale: {
id: 'sale-1', id: "sale-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
platformFeesMinor: 100, platformFeesMinor: 100,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
@@ -52,85 +52,105 @@ test('creates a sale through the repository with validated money values', async
netProceedsMinor: 2399, netProceedsMinor: 2399,
profitMinor: 1199, profitMinor: 1199,
}); });
assert.equal(received.sale.sellerId, 'seller-1'); assert.equal(received.sale.sellerId, "seller-1");
assert.equal(received.sale.soldPriceMinor, 2499); assert.equal(received.sale.soldPriceMinor, 2499);
assert.equal(received.costBasisMinor, 1200); assert.equal(received.costBasisMinor, 1200);
}); });
test('lists seller sales with derived net proceeds and profit', async () => { test("lists seller sales with derived net proceeds and profit", async () => {
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1'); assert.equal(sellerId, "seller-1");
return [{ return [
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 }, {
saleItem: { costBasisMinor: 1500 }, sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
inventoryItem: { id: 'inventory-1', status: 'SOLD' }, saleItem: { costBasisMinor: 1500 },
}]; inventoryItem: { id: "inventory-1", status: "SOLD" },
},
];
}, },
}, },
}); });
const [sale] = await service.list({ sellerId: 'seller-1' }); const [sale] = await service.list({ sellerId: "seller-1" });
assert.equal(sale.netProceedsMinor, 2800); assert.equal(sale.netProceedsMinor, 2800);
assert.equal(sale.profitMinor, 1300); assert.equal(sale.profitMinor, 1300);
}); });
test('voids an owned sale with a required reason', async () => { test("voids an owned sale with a required reason", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async voidForSeller(input) { async voidForSeller(input) {
received = input; received = input;
return { return {
status: 'VOIDED', status: "VOIDED",
sale: { id: 'sale-1', soldPriceMinor: 3000 }, sale: { id: "sale-1", soldPriceMinor: 3000 },
saleItem: { costBasisMinor: 1500 }, saleItem: { costBasisMinor: 1500 },
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' }, inventoryItem: { id: "inventory-1", status: "ACTIVE" },
}; };
}, },
}, },
}); });
const result = await service.void({ const result = await service.void({
saleId: 'sale-1', saleId: "sale-1",
sellerId: 'seller-1', sellerId: "seller-1",
actorUserId: 'user-1', actorUserId: "user-1",
reason: ' Duplicate sale ', reason: " Duplicate sale ",
}); });
assert.equal(received.reason, 'Duplicate sale'); assert.equal(received.reason, "Duplicate sale");
assert.equal(result.inventoryItem.status, 'ACTIVE'); assert.equal(result.inventoryItem.status, "ACTIVE");
await assert.rejects( await assert.rejects(
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }), () =>
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400, service.void({
saleId: "sale-1",
sellerId: "seller-1",
actorUserId: "user-1",
reason: "x",
}),
(error) =>
error.code === "SALE_VOID_REASON_REQUIRED" && error.statusCode === 400,
); );
}); });
test('creates one sale containing multiple inventory items', async () => { test("creates one sale containing multiple inventory items", async () => {
let received; let received;
const service = createSaleService({ const service = createSaleService({
saleRepository: { saleRepository: {
async createForInventoryItems(input) { async createForInventoryItems(input) {
received = input; received = input;
return { return {
status: 'CREATED', status: "CREATED",
sale: { id: 'sale-2', soldPriceMinor: 3000 }, sale: { id: "sale-2", soldPriceMinor: 3000 },
saleItems: input.items, saleItems: input.items,
saleItem: input.items[0], saleItem: input.items[0],
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })), inventoryItems: input.items.map(({ inventoryItemId }) => ({
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' }, id: inventoryItemId,
status: "SOLD",
})),
inventoryItem: { id: input.items[0].inventoryItemId, status: "SOLD" },
}; };
}, },
}, },
}); });
const result = await service.create({ const result = await service.create({
sellerId: 'seller-1', sellerId: "seller-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
items: [ items: [
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 }, {
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 }, inventoryItemId: "inventory-1",
soldPriceMinor: 1200,
costBasisMinor: 500,
},
{
inventoryItemId: "inventory-2",
soldPriceMinor: 1800,
costBasisMinor: 700,
},
], ],
}); });
@@ -139,18 +159,27 @@ test('creates one sale containing multiple inventory items', async () => {
assert.equal(result.profitMinor, 1800); assert.equal(result.profitMinor, 1800);
}); });
test('maps missing and already-sold inventory to application errors', async () => { test("maps missing and already-sold inventory to application errors", async () => {
for (const result of [ for (const result of [
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 }, { status: "NOT_FOUND", code: "INVENTORY_NOT_FOUND", statusCode: 404 },
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 }, {
status: "NOT_ACTIVE",
code: "INVENTORY_NOT_AVAILABLE_FOR_SALE",
statusCode: 409,
},
]) { ]) {
const service = createSaleService({ const service = createSaleService({
saleRepository: { async createForInventoryItem() { return result; } }, saleRepository: {
async createForInventoryItem() {
return result;
},
},
}); });
await assert.rejects( await assert.rejects(
() => service.create(validInput()), () => service.create(validInput()),
(error) => error.code === result.code && error.statusCode === result.statusCode, (error) =>
error.code === result.code && error.statusCode === result.statusCode,
); );
} }
}); });
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSavedViewService } from '../../src/modules/search/services/saved-view-service.js';
test('creates and merges a seller saved view through the repository', async () => {
const calls = [];
const service = createSavedViewService({
savedViewRepository: {
async create(sellerId, view) { calls.push([sellerId, view]); return { id: 'view-1', sellerId, ...view }; },
},
});
const result = await service.create({ sellerId: 'seller-1', view: { name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } } });
assert.deepEqual(result, { id: 'view-1', sellerId: 'seller-1', name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } });
assert.deepEqual(calls, [['seller-1', { name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } }]]);
});
test('rejects an unknown saved-view filter value', async () => {
const service = createSavedViewService({ savedViewRepository: {} });
await assert.rejects(() => service.create({ sellerId: 'seller-1', view: { name: 'Bad', filters: { query: { nested: true } } } }));
});
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
test('merges validated seller settings without replacing other categories', async () => {
let stored = null;
const service = createSellerSettingsService({
sellerSettingsRepository: {
async findBySellerId() { return stored; },
async upsert(sellerId, settings) {
stored = { sellerId, settings };
return stored;
},
},
});
await service.update({
sellerId: 'seller-1',
settings: { pricing: { defaultStrategy: 'median' }, general: { publicContactEmail: 'owner@example.test' } },
});
const result = await service.update({
sellerId: 'seller-1',
settings: { inventory: { maxImagesPerItem: 12 } },
});
assert.deepEqual(result, {
pricing: { defaultStrategy: 'median' },
general: { publicContactEmail: 'owner@example.test' },
inventory: { maxImagesPerItem: 12 },
});
});
test('rejects unknown or invalid settings', async () => {
const service = createSellerSettingsService({
sellerSettingsRepository: { async findBySellerId() { return null; } },
});
await assert.rejects(
() => service.update({ sellerId: 'seller-1', settings: { secrets: { token: 'nope' } } }),
);
});
test('seller descriptions are trimmed, bounded, preserved on partial updates and clearable', async () => {
let stored;
const service = createSellerSettingsService({ sellerSettingsRepository: {
async findBySellerId() { return stored; },
async upsert(sellerId, settings) { stored = { sellerId, settings }; return stored; },
} });
const update = (general) => service.update({ sellerId: 'seller-1', settings: { general } });
assert.equal((await update({ publicDescription: ' Collector. ' })).general.publicDescription, 'Collector.');
assert.equal((await update({ publicContactPhone: '555-0100' })).general.publicDescription, 'Collector.');
await assert.rejects(() => update({ publicDescription: 'x'.repeat(2001) }));
await assert.rejects(() => update({ publicDescription: 123 }));
assert.equal((await update({ publicDescription: 'x'.repeat(2000) })).general.publicDescription.length, 2000);
assert.equal((await update({ publicDescription: ' ' })).general.publicDescription, '');
});
@@ -1,17 +1,17 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
calculateAcquisitionCostBasis, calculateAcquisitionCostBasis,
createAcquisition, createAcquisition,
} from '../../src/modules/acquisitions/domain/acquisition.js'; } from "../../src/modules/acquisitions/domain/acquisition.js";
import { import {
calculateMedianPriceMinor, calculateMedianPriceMinor,
createPricingObservation, createPricingObservation,
identifyOutlierPrices, identifyOutlierPrices,
} from '../../src/modules/pricing/domain/pricing-observation.js'; } from "../../src/modules/pricing/domain/pricing-observation.js";
test('calculates acquisition cost basis from all cost components', () => { test("calculates acquisition cost basis from all cost components", () => {
const total = calculateAcquisitionCostBasis({ const total = calculateAcquisitionCostBasis({
costMinor: 1000, costMinor: 1000,
shippingMinor: 150, shippingMinor: 150,
@@ -23,43 +23,48 @@ test('calculates acquisition cost basis from all cost components', () => {
assert.equal(total, 1075); assert.equal(total, 1075);
}); });
test('rejects negative acquisition values and missing identity fields', () => { test("rejects negative acquisition values and missing identity fields", () => {
assert.throws( assert.throws(
() => createAcquisition({ () =>
inventoryItemId: 'inventory-item-id', createAcquisition({
method: 'PURCHASE', inventoryItemId: "inventory-item-id",
costMinor: -1, method: "PURCHASE",
}), costMinor: -1,
}),
/must be a non-negative integer minor unit/, /must be a non-negative integer minor unit/,
); );
assert.throws( assert.throws(
() => createAcquisition({ () =>
inventoryItemId: '', createAcquisition({
method: 'PURCHASE', inventoryItemId: "",
costMinor: 1000, method: "PURCHASE",
}), costMinor: 1000,
}),
/Inventory item ID is required/, /Inventory item ID is required/,
); );
}); });
test('creates pricing observations and calculates a median observed price', () => { test("creates pricing observations and calculates a median observed price", () => {
const observation = createPricingObservation({ const observation = createPricingObservation({
productId: 'product-id', productId: "product-id",
inventoryItemId: 'inventory-item-id', inventoryItemId: "inventory-item-id",
providerId: 'ebay', providerId: "ebay",
priceMinor: 2499, priceMinor: 2499,
currency: 'USD', currency: "USD",
sourceIdentifier: 'listing-1', sourceIdentifier: "listing-1",
conditionId: 'condition-id', conditionId: "condition-id",
}); });
assert.equal(observation.currency, 'USD'); assert.equal(observation.currency, "USD");
assert.equal(observation.priceMinor, 2499); assert.equal(observation.priceMinor, 2499);
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000); assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
}); });
test('identifies extreme pricing observations without discarding normal values', () => { test("identifies extreme pricing observations without discarding normal values", () => {
assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]); assert.deepEqual(
[...identifyOutlierPrices([1000, 1050, 1100, 5000])],
[5000],
);
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0); assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
}); });
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { moveImageInOrder } from '../../src/modules/inventory/domain/image-order.js';
test('moves one image next to its neighbor without changing the input order', () => {
const images = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(moveImageInOrder(images, 'b', 'up').map(({ id }) => id), ['b', 'a', 'c']);
assert.deepEqual(moveImageInOrder(images, 'b', 'down').map(({ id }) => id), ['a', 'c', 'b']);
assert.deepEqual(images.map(({ id }) => id), ['a', 'b', 'c']);
assert.equal(moveImageInOrder(images, 'a', 'up'), images);
assert.equal(moveImageInOrder(images, 'c', 'down'), images);
assert.throws(() => moveImageInOrder(images, 'a', 'left'), { code: 'IMAGE_MOVE_INVALID' });
});
+1
View File
@@ -14,6 +14,7 @@ test('creates a product with required product identity fields', () => {
name: 'Charizard ex', name: 'Charizard ex',
categoryId: 'category-id', categoryId: 'category-id',
sellerId: 'seller-id', sellerId: 'seller-id',
tags: [],
}); });
}); });
+49 -42
View File
@@ -1,76 +1,83 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
calculateNetSaleProceeds, calculateNetSaleProceeds,
createSale, createSale,
createSaleItem, createSaleItem,
} from '../../src/modules/sales/domain/sale.js'; } from "../../src/modules/sales/domain/sale.js";
test('creates a valid sale record and calculates net proceeds after fees', () => { test("creates a valid sale record and calculates net proceeds after fees", () => {
const sale = createSale({ const sale = createSale({
sellerId: 'seller-id', sellerId: "seller-id",
saleDate: '2026-09-08', saleDate: "2026-09-08",
saleLocation: 'eBay', saleLocation: "eBay",
soldPriceMinor: 4999, soldPriceMinor: 4999,
currency: 'USD', currency: "USD",
platformFeesMinor: 300, platformFeesMinor: 300,
paymentProcessingFeesMinor: 120, paymentProcessingFeesMinor: 120,
shippingCostMinor: 50, shippingCostMinor: 50,
taxesMinor: 90, taxesMinor: 90,
otherFeesMinor: 40, otherFeesMinor: 40,
buyerName: 'Alex', buyerName: "Alex",
buyerContact: 'alex@example.com', buyerContact: "alex@example.com",
}); });
assert.equal(sale.soldPriceMinor, 4999); assert.equal(sale.soldPriceMinor, 4999);
assert.equal(sale.currency, 'USD'); assert.equal(sale.currency, "USD");
assert.equal(calculateNetSaleProceeds({ assert.equal(
soldPriceMinor: 4999, calculateNetSaleProceeds({
platformFeesMinor: 300, soldPriceMinor: 4999,
paymentProcessingFeesMinor: 120, platformFeesMinor: 300,
shippingCostMinor: 50, paymentProcessingFeesMinor: 120,
taxesMinor: 90, shippingCostMinor: 50,
otherFeesMinor: 40, taxesMinor: 90,
}), 4399); otherFeesMinor: 40,
}),
4399,
);
}); });
test('rejects invalid sales and negative fee values', () => { test("rejects invalid sales and negative fee values", () => {
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-09-08', sellerId: "seller-id",
soldPriceMinor: 0, saleDate: "2026-09-08",
}), soldPriceMinor: 0,
}),
/must be a positive integer minor unit/, /must be a positive integer minor unit/,
); );
assert.throws( assert.throws(
() => createSaleItem({ () =>
saleId: 'sale-id', createSaleItem({
inventoryItemId: 'inventory-item-id', saleId: "sale-id",
soldPriceMinor: 2000, inventoryItemId: "inventory-item-id",
costBasisMinor: -1, soldPriceMinor: 2000,
}), costBasisMinor: -1,
}),
/must be a non-negative integer minor unit/, /must be a non-negative integer minor unit/,
); );
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-02-30', sellerId: "seller-id",
soldPriceMinor: 2000, saleDate: "2026-02-30",
}), soldPriceMinor: 2000,
}),
/must be a valid ISO date/, /must be a valid ISO date/,
); );
assert.throws( assert.throws(
() => createSale({ () =>
sellerId: 'seller-id', createSale({
saleDate: '2026-09-08', sellerId: "seller-id",
soldPriceMinor: 2000, saleDate: "2026-09-08",
currency: 'US dollars', soldPriceMinor: 2000,
}), currency: "US dollars",
}),
/must be a three-letter ISO currency code/, /must be a three-letter ISO currency code/,
); );
}); });
+58 -20
View File
@@ -1,16 +1,19 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { import {
buildSearchFilters, buildSearchFilters,
createSearchQuery, createSearchQuery,
normalizeSearchQuery, normalizeSearchQuery,
} from '../../src/modules/search/domain/search-query.js'; } from "../../src/modules/search/domain/search-query.js";
test('normalizes a public search query and keeps filter values within bounds', () => { test("normalizes a public search query and keeps filter values within bounds", () => {
const query = createSearchQuery({ const query = createSearchQuery({
query: ' Charizard ex ', query: " Charizard ex ",
category: 'Cards', category: "Cards",
setName: "SV151",
eventName: "Worlds",
tag: "Binder",
minPriceMinor: 1000, minPriceMinor: 1000,
maxPriceMinor: 5000, maxPriceMinor: 5000,
page: 1, page: 1,
@@ -18,8 +21,11 @@ test('normalizes a public search query and keeps filter values within bounds', (
includeSold: false, includeSold: false,
}); });
assert.equal(query.text, 'charizard ex'); assert.equal(query.text, "charizard ex");
assert.equal(query.category, 'Cards'); assert.equal(query.category, "Cards");
assert.equal(query.setName, "SV151");
assert.equal(query.eventName, "Worlds");
assert.equal(query.tag, "binder");
assert.equal(query.minPriceMinor, 1000); assert.equal(query.minPriceMinor, 1000);
assert.equal(query.maxPriceMinor, 5000); assert.equal(query.maxPriceMinor, 5000);
assert.equal(query.page, 1); assert.equal(query.page, 1);
@@ -27,25 +33,57 @@ test('normalizes a public search query and keeps filter values within bounds', (
assert.equal(query.includeSold, false); assert.equal(query.includeSold, false);
}); });
test('rejects invalid pagination and price boundaries', () => { test("rejects invalid pagination and price boundaries", () => {
assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/); assert.throws(
assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/); () => createSearchQuery({ query: "abc", page: 0, limit: 20 }),
assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/); /page must be >= 1/,
);
assert.throws(
() =>
createSearchQuery({
query: "abc",
minPriceMinor: 5000,
maxPriceMinor: 1000,
}),
/minPriceMinor must be <= maxPriceMinor/,
);
assert.throws(
() => createSearchQuery({ query: "abc", limit: 0 }),
/limit must be between 1 and 100/,
);
}); });
test('builds a public query filter set that hides sold items by default', () => { test("builds a public query filter set that hides sold items by default", () => {
const filters = buildSearchFilters({ includeSold: false }); const filters = buildSearchFilters({ includeSold: false });
assert.equal(filters.status, 'AVAILABLE'); assert.equal(filters.status, "AVAILABLE");
assert.equal(filters.includeSold, false); assert.equal(filters.includeSold, false);
assert.deepEqual(filters.sort, ['recently_added']); assert.deepEqual(filters.sort, ["recently_added"]);
}); });
test('accepts only the supported public search sort values', () => { test("accepts only the supported public search sort values", () => {
assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low'); assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/); assert.equal(createSearchQuery({ sort: "updated" }).sort, "updated");
assert.equal(createSearchQuery({ sort: "name_asc" }).sort, "name_asc");
assert.equal(createSearchQuery({ sort: "name_desc" }).sort, "name_desc");
assert.equal(createSearchQuery({ sort: "market_low" }).sort, "market_low");
assert.equal(createSearchQuery({ sort: "market_high" }).sort, "market_high");
assert.throws(
() => createSearchQuery({ sort: "random" }),
/sort must be recent/,
);
}); });
test('normalizes whitespace and punctuation in free-text search terms', () => { test("accepts public status and newly-added filters", () => {
assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo'); const query = createSearchQuery({ status: "SOLD", newlyAdded: true });
assert.equal(query.status, "SOLD");
assert.equal(query.newlyAdded, true);
assert.throws(() => createSearchQuery({ status: "UNKNOWN" }), /status must be/);
});
test("normalizes whitespace and punctuation in free-text search terms", () => {
assert.equal(
normalizeSearchQuery(" Pikachu #199 / holo "),
"pikachu 199 holo",
);
}); });
+23 -19
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
return buildApp({ return buildApp({
@@ -9,7 +9,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
authorizeUnsafeRequest, authorizeUnsafeRequest,
acquisitionService: { acquisitionService: {
async create(input) { async create(input) {
return { id: 'acquisition-1', ...input, costBasisMinor: 1075 }; return { id: "acquisition-1", ...input, costBasisMinor: 1075 };
}, },
async list() { async list() {
return []; return [];
@@ -18,21 +18,23 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
}); });
} }
test('creates an acquisition for the authenticated seller', async (testContext) => { test("creates an acquisition for the authenticated seller", async (testContext) => {
let csrfChecked = false; let csrfChecked = false;
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecked = true; }, authorizeUnsafeRequest: async () => {
csrfChecked = true;
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/acquisitions', url: "/api/v1/admin/acquisitions",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { payload: {
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
}, },
}); });
@@ -42,16 +44,18 @@ test('creates an acquisition for the authenticated seller', async (testContext)
assert.equal(csrfChecked, true); assert.equal(csrfChecked, true);
}); });
test('rejects invalid acquisition requests', async (testContext) => { test("rejects invalid acquisition requests", async (testContext) => {
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/acquisitions', url: "/api/v1/admin/acquisitions",
payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 }, payload: { inventoryItemId: "not-a-uuid", method: "", costMinor: -1 },
}); });
assert.equal(response.statusCode, 400); assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'VALIDATION_ERROR'); assert.equal(response.json().error.code, "VALIDATION_ERROR");
}); });
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js';
test('downloads seller-scoped inventory JSON for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: createAdminExportService({
inventoryItemService: {
async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1');
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
}];
},
},
}),
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.json',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.json"');
assert.deepEqual(JSON.parse(response.body), [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
}]);
});
test('requires authentication for inventory JSON exports', async (testContext) => {
const app = buildApp({
adminExportService: createAdminExportService({
inventoryItemService: { async listBySellerId() { return []; } },
}),
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.json',
});
assert.equal(response.statusCode, 401);
assert.equal(response.json().error.code, 'UNAUTHORIZED');
});
test('downloads seller-scoped inventory CSV for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async inventoryCsv({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return 'id,status\r\n"item-1","ACTIVE"';
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.csv',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'text/csv; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
});
test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async inventoryExcel({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return '<?xml version="1.0"?><Workbook />';
},
},
});
testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"');
assert.equal(response.body, '<?xml version="1.0"?><Workbook />');
});
test('enqueues and polls an asynchronous inventory export', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async enqueueInventoryJson({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return { id: 'job-1', status: 'PENDING', createdAt: '2026-09-10' };
},
async getExportJob({ sellerId, jobId }) {
assert.equal(sellerId, 'seller-1');
assert.equal(jobId, 'job-1');
return { id: jobId, format: 'JSON', status: 'COMPLETED', output: [{ id: 'item-1' }] };
},
},
});
testContext.after(() => app.close());
const enqueue = await app.inject({ method: 'POST', url: '/api/v1/admin/exports/inventory.json/jobs' });
const poll = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1' });
assert.equal(enqueue.statusCode, 202);
assert.equal(enqueue.json().data.id, 'job-1');
assert.equal(poll.statusCode, 200);
assert.deepEqual(poll.json().data.output, [{ id: 'item-1' }]);
const download = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1/download' });
assert.equal(download.statusCode, 200);
assert.equal(download.headers['content-disposition'], 'attachment; filename="inventory.json"');
assert.deepEqual(JSON.parse(download.body), [{ id: 'item-1' }]);
});
@@ -5,8 +5,9 @@ import { buildApp } from '../../src/app/build-app.js';
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js'; import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
import { createProductService } from '../../src/modules/products/services/product-service.js'; import { createProductService } from '../../src/modules/products/services/product-service.js';
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingService } = {}) {
const products = []; const products = [];
const inventoryItems = [];
const productRepository = { const productRepository = {
async create(product) { async create(product) {
const createdProduct = { const createdProduct = {
@@ -49,15 +50,30 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
}, },
inventoryItemRepository: { inventoryItemRepository: {
async create(inventoryItem) { async create(inventoryItem) {
return { const createdItem = {
id: 'inventory-1', id: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
...inventoryItem, ...inventoryItem,
status: 'ACTIVE', status: 'ACTIVE',
createdAt: new Date('2026-09-07T00:00:00.000Z'), createdAt: new Date('2026-09-07T00:00:00.000Z'),
updatedAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'),
}; };
inventoryItems.push(createdItem);
return createdItem;
},
async listBySellerId() {
return inventoryItems;
},
async archiveByIdsForSeller(ids) {
return inventoryItems.filter(({ id }) => ids.includes(id)).map((item) => {
item.status = 'ARCHIVED';
return item;
});
},
async updateProductTagsByIdsForSeller() {
return [];
}, },
}, },
pricingService,
}), }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
}); });
@@ -146,6 +162,133 @@ test('creates inventory only for the authenticated sellers product', async (test
assert.equal(response.json().data.status, 'ACTIVE'); assert.equal(response.json().data.status, 'ACTIVE');
}); });
test('archives selected inventory through the authenticated bulk route', async (testContext) => {
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'ARCHIVE',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
},
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().data[0].status, 'ARCHIVED');
});
test('enqueues selected inventory pricing refreshes through the bulk route', async (testContext) => {
const enqueued = [];
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
pricingService: {
async enqueueRefresh(input) {
enqueued.push(input);
return {
id: 'job-1',
status: 'PENDING',
createdAt: new Date('2026-09-10T00:00:00.000Z'),
};
},
},
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'PRICING_REFRESH',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
providerId: 'fixture-provider',
},
});
assert.equal(response.statusCode, 202);
assert.deepEqual(enqueued, [{
sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f',
inventoryItemId: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
providerId: 'fixture-provider',
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
}]);
assert.equal(response.json().data[0].status, 'PENDING');
});
test('updates Product tags through the authenticated bulk route', async (testContext) => {
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
tags: ['Keep'],
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'TAGS',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
add: ['Binder'],
remove: ['sell'],
},
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, []);
});
test('rejects an authenticated product write without a CSRF token', async (testContext) => { test('rejects an authenticated product write without a CSRF token', async (testContext) => {
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }), authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
function createTestApp({ authorizeUnsafeRequest } = {}) {
const views = [];
return buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest,
savedViewService: {
async list() { return views; },
async create({ view }) { const result = { id: 'view-1', ...view }; views.push(result); return result; },
async update({ view }) { return { id: 'view-1', ...view }; },
async delete() { return { id: 'view-1' }; },
},
});
}
test('creates, lists, updates, and deletes saved views', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const createResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Needs Attention', filters: { missingImages: true } } });
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/saved-views' });
const updateResponse = await app.inject({ method: 'PATCH', url: '/api/v1/admin/saved-views/view-1', payload: { name: 'Recent', filters: { sort: 'recent' } } });
const deleteResponse = await app.inject({ method: 'DELETE', url: '/api/v1/admin/saved-views/view-1' });
assert.equal(createResponse.statusCode, 201);
assert.equal(listResponse.statusCode, 200);
assert.equal(updateResponse.statusCode, 200);
assert.equal(deleteResponse.statusCode, 204);
});
test('protects saved-view mutations with CSRF validation', async (testContext) => {
const app = createTestApp({
authorizeUnsafeRequest: async () => {
const error = new Error('CSRF validation failed.');
error.code = 'FORBIDDEN';
error.statusCode = 403;
throw error;
},
});
testContext.after(() => app.close());
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Recent', filters: {} } });
assert.equal(response.statusCode, 403);
});
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { buildApp } from '../../src/app/build-app.js';
function createTestApp({ authorizeUnsafeRequest } = {}) {
let settings = { pricing: { defaultStrategy: 'median' } };
return buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest,
sellerSettingsService: createSellerSettingsService({ sellerSettingsRepository: {
async findBySellerId(sellerId) { assert.equal(sellerId, 'seller-1'); return { settings }; },
async upsert(sellerId, nextSettings) {
assert.equal(sellerId, 'seller-1');
settings = nextSettings;
return { settings };
},
} }),
});
}
test('reads and updates seller settings', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const readResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
const updateResponse = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { inventory: { maxImagesPerItem: 12 } },
});
assert.equal(readResponse.statusCode, 200);
assert.deepEqual(readResponse.json().data, { pricing: { defaultStrategy: 'median' } });
assert.equal(updateResponse.statusCode, 200);
assert.deepEqual(updateResponse.json().data, {
pricing: { defaultStrategy: 'median' },
inventory: { maxImagesPerItem: 12 },
});
});
test('protects settings updates with CSRF validation', async (testContext) => {
const app = createTestApp({
authorizeUnsafeRequest: async () => {
const error = new Error('CSRF validation failed.');
error.code = 'FORBIDDEN';
error.statusCode = 403;
throw error;
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { pricing: { defaultStrategy: 'median' } },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error.code, 'FORBIDDEN');
});
test('clears public contacts explicitly while omitted fields retain their values', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const patch = (general) => app.inject({ method: 'PATCH', url: '/api/v1/admin/settings', payload: { general } });
assert.equal((await patch({ publicContactEmail: ' owner@example.test ', publicContactPhone: '555-0100', publicDescription: 'Collector.' })).statusCode, 200);
const clearedEmail = await patch({ publicContactEmail: ' ' });
assert.equal(clearedEmail.statusCode, 200);
assert.deepEqual(clearedEmail.json().data.general, { publicContactEmail: '', publicContactPhone: '555-0100', publicDescription: 'Collector.' });
assert.equal((await patch({ publicContactPhone: '' })).statusCode, 200);
const read = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
assert.deepEqual(read.json().data.general, { publicContactEmail: '', publicContactPhone: '', publicDescription: 'Collector.' });
for (const general of [{ publicContactEmail: 'invalid' }, { publicContactEmail: null }, { publicContactPhone: 'x'.repeat(51) }]) {
assert.equal((await patch(general)).statusCode, 400);
}
});
+27 -9
View File
@@ -1,16 +1,34 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('returns a seller-scoped dashboard', async (testContext) => { test("returns a seller-scoped dashboard", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } }, dashboardService: {
async get({ sellerId }) {
assert.equal(sellerId, "seller-1");
return {
counts: {},
financials: {},
recentSales: [],
recentAcquisitions: [],
};
},
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' }); const response = await app.inject({
method: "GET",
url: "/api/v1/admin/dashboard",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }); assert.deepEqual(response.json().data, {
}); counts: {},
financials: {},
recentSales: [],
recentAcquisitions: [],
});
});
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
test('validates and authorizes image moves and exposes only the image DTO', async (context) => {
const calls = [];
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest: async () => { calls.push('csrf'); },
inventoryImageService: { async move(input) { calls.push(input); return { id: input.imageId, sortOrder: 1, privateData: 'hidden' }; } },
});
context.after(() => app.close());
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'down' } });
assert.equal(response.statusCode, 200);
assert.deepEqual(calls, ['csrf', { imageId: 'image-1', direction: 'down', sellerId: 'seller-1' }]);
assert.equal(response.json().data.sortOrder, 1);
assert.equal(response.json().data.privateData, undefined);
for (const payload of [{}, { direction: 'left' }, { direction: 'up', sellerId: 'other' }]) {
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload })).statusCode, 400);
}
});
test('rejects unauthenticated and CSRF-blocked moves without calling the service', async (context) => {
for (const csrf of [false, true]) {
const app = buildApp({
authenticateAdmin: async () => csrf ? { sellerId: 'seller-1' } : null,
authorizeUnsafeRequest: async () => { throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); },
inventoryImageService: { async move() { assert.fail('unauthorized move'); } },
});
context.after(() => app.close());
assert.equal((await app.inject({ method: 'POST', url: '/api/v1/admin/images/image-1/move', payload: { direction: 'up' } })).statusCode, csrf ? 403 : 401);
}
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
test('rejects malformed image updates without writes and accepts normalized boundary values', async (context) => {
const writes = [];
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
inventoryImageService: createInventoryImageService({ inventoryImageRepository: {
async updateOrderForSeller(id, sortOrder, sellerId) { writes.push({ id, sortOrder, sellerId }); return { id, sortOrder }; },
async updateAltTextForSeller(id, altText, sellerId) { writes.push({ id, altText, sellerId }); return { id, altText }; },
} }),
});
context.after(() => app.close());
const patch = (path, payload) => app.inject({ method: 'PATCH', url: `/api/v1/admin/images/image-1/${path}`, payload });
for (const payload of [{}, { sortOrder: -1 }, { sortOrder: 0.2 }, { sortOrder: '1' }, { sortOrder: null }, { sortOrder: 2147483648 }, { sortOrder: 0, sellerId: 'other' }]) {
const response = await patch('order', payload);
assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
}
for (const payload of [{}, { altText: 1 }, { altText: {} }, { altText: [] }, { altText: 'x'.repeat(1001) }, { altText: 'Front', private: true }]) {
assert.equal((await patch('alt-text', payload)).statusCode, 400);
}
assert.deepEqual(writes, []);
for (const sortOrder of [0, 2147483647]) assert.equal((await patch('order', { sortOrder })).json().data.sortOrder, sortOrder);
for (const [altText, expected] of [[' Front ', 'Front'], [' ', ''], [null, null], ['x'.repeat(1000), 'x'.repeat(1000)]]) {
const response = await patch('alt-text', { altText });
assert.equal(response.statusCode, 200);
assert.equal(response.json().data.altText, expected);
}
assert.ok(writes.every(({ sellerId }) => sellerId === 'seller-1'));
});
test('image update validation does not bypass authentication or CSRF', async (context) => {
for (const signedIn of [false, true]) {
const app = buildApp({
authenticateAdmin: async () => signedIn ? { sellerId: 'seller-1' } : null,
authorizeUnsafeRequest: async () => { throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); },
inventoryImageService: {
async updateOrder() { assert.fail('blocked request reached order service'); },
async updateAltText() { assert.fail('blocked request reached alt-text service'); },
},
});
context.after(() => app.close());
for (const path of ['order', 'alt-text']) {
const response = await app.inject({ method: 'PATCH', url: `/api/v1/admin/images/image-1/${path}`, payload: {} });
assert.equal(response.statusCode, signedIn ? 403 : 401);
}
}
});
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { buildApp } from '../../src/app/build-app.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
import { createSharpImageInspector } from '../../src/infrastructure/images/sharp-image-inspector.js';
test('multipart uploads derive dimensions and reject malformed bytes before writes', async (context) => {
const saved = [];
let stored = 0;
let jobs = 0;
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
inventoryImageService: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: { async put() { stored += 1; }, async delete() {} },
inventoryImageRepository: { async createForSeller(input, sellerId) { assert.equal(sellerId, 'seller-1'); saved.push(input); return { id: 'image-1', ...input }; } },
imageDerivativeJobRepository: { async create() { jobs += 1; } },
}),
});
context.after(() => app.close());
const data = await sharp({ create: { width: 7, height: 5, channels: 3, background: 'white' } }).png().toBuffer();
const send = (bytes, fields = '') => app.inject({
method: 'POST', url: '/api/v1/admin/inventory/item-1/images',
headers: { 'content-type': 'multipart/form-data; boundary=image-test' },
payload: Buffer.concat([Buffer.from(`${fields}--image-test\r\nContent-Disposition: form-data; name="file"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`), bytes, Buffer.from('\r\n--image-test--\r\n')]),
});
for (const fields of ['', '--image-test\r\nContent-Disposition: form-data; name="width"\r\n\r\n1\r\n--image-test\r\nContent-Disposition: form-data; name="height"\r\n\r\n1\r\n']) {
const response = await send(data, fields);
assert.equal(response.statusCode, 201);
assert.equal(response.json().data.width, 7);
assert.equal(response.json().data.height, 5);
assert.equal(response.json().data.fileSize, data.length);
}
const response = await send(data.subarray(0, 40));
assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'IMAGE_DECODE_INVALID');
assert.equal(saved.length, 2);
assert.equal(stored, 2);
assert.equal(jobs, 2);
});
+149 -47
View File
@@ -1,25 +1,54 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('accepts a protected pricing observation and valuation request', async (testContext) => { test("accepts a protected pricing observation and valuation request", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
pricingService: { pricingService: {
async addObservation(input) { return { id: 'observation-1', ...input }; }, async addObservation(input) {
async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; }, return { id: "observation-1", ...input };
},
async calculate() {
return {
valuation: {
id: "valuation-1",
inventoryItemId: "inventory-1",
valueMinor: 2500,
currency: "USD",
strategy: "MEDIAN",
confidence: "LOW",
sourceCount: 1,
},
observations: [],
};
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const observationResponse = await app.inject({ const observationResponse = await app.inject({
method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' }, method: "POST",
payload: { productId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958' }, url: "/api/v1/admin/pricing/observations",
headers: { "x-csrf-token": "csrf" },
payload: {
productId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
inventoryItemId: "21eb218b-7a95-4f80-a9e3-e306f7ca1f2e",
providerId: "manual",
priceMinor: 2500,
sourceIdentifier: "source-1",
conditionId: "ab8150c7-96c1-4b55-8d69-7d0b77d48958",
},
});
const valuationResponse = await app.inject({
method: "POST",
url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation",
}); });
const valuationResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation' });
assert.equal(observationResponse.statusCode, 201); assert.equal(observationResponse.statusCode, 201);
assert.equal(valuationResponse.statusCode, 200); assert.equal(valuationResponse.statusCode, 200);
@@ -27,59 +56,74 @@ test('accepts a protected pricing observation and valuation request', async (tes
assert.equal(csrfChecks, 2); assert.equal(csrfChecks, 2);
}); });
test('accepts a CSRF-protected market-value override', async (testContext) => { test("accepts a CSRF-protected market-value override", async (testContext) => {
let received; let received;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } }, pricingService: {
async override(input) {
received = input;
return { id: "override-1", ...input };
},
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override', url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { valueMinor: 2600, reason: 'Verified recent sale' }, payload: { valueMinor: 2600, reason: "Verified recent sale" },
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(received.actorUserId, 'user-1'); assert.equal(received.actorUserId, "user-1");
assert.equal(response.json().data.valueMinor, 2600); assert.equal(response.json().data.valueMinor, 2600);
}); });
test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => { test("enqueues a pricing refresh and exposes seller-scoped job status", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { pricingService: {
async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; }, async enqueueRefresh(input) {
async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; }, return { id: "job-1", status: "PENDING", createdAt: input.sellerId };
},
async getJob(input) {
assert.equal(input.sellerId, "seller-1");
return { id: input.jobId, status: "COMPLETED", error: null };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/inventory-1/pricing/refresh', url: "/api/v1/admin/inventory/inventory-1/pricing/refresh",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { providerId: 'missing' }, payload: { providerId: "missing" },
}); });
assert.equal(response.statusCode, 202); assert.equal(response.statusCode, 202);
assert.equal(response.json().data.status, 'PENDING'); assert.equal(response.json().data.status, "PENDING");
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' }); const jobResponse = await app.inject({
method: "GET",
url: "/api/v1/admin/jobs/job-1",
});
assert.equal(jobResponse.statusCode, 200); assert.equal(jobResponse.statusCode, 200);
assert.equal(jobResponse.json().data.status, 'COMPLETED'); assert.equal(jobResponse.json().data.status, "COMPLETED");
}); });
test('returns a conflict when refresh has no active product mapping', async (testContext) => { test("returns a conflict when refresh has no active product mapping", async (testContext) => {
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
pricingService: { pricingService: {
async enqueueRefresh() { async enqueueRefresh() {
const error = new Error('No active pricing provider mapping exists for this product.'); const error = new Error(
error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND'; "No active pricing provider mapping exists for this product.",
);
error.code = "PRICING_PROVIDER_MAPPING_NOT_FOUND";
error.statusCode = 409; error.statusCode = 409;
throw error; throw error;
}, },
@@ -88,30 +132,88 @@ test('returns a conflict when refresh has no active product mapping', async (tes
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/inventory/item-1/pricing/refresh', url: "/api/v1/admin/inventory/item-1/pricing/refresh",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { productId: 'product-1', conditionId: 'condition-1' }, payload: { productId: "product-1", conditionId: "condition-1" },
}); });
assert.equal(response.statusCode, 409); assert.equal(response.statusCode, 409);
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND'); assert.equal(
response.json().error.code,
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
);
}); });
test('lists jobs and retries a failed job with CSRF protection', async (testContext) => { test("lists jobs and retries a failed job with CSRF protection", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
pricingService: { pricingService: {
async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; }, async listJobs() {
async retryJob() { return { id: 'job-1', status: 'PENDING' }; }, return [
{
id: "job-1",
status: "FAILED",
providerId: "fixture",
inventoryItemId: "item-1",
},
];
},
async retryJob() {
return { id: "job-1", status: "PENDING" };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' }); const listResponse = await app.inject({
const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } }); method: "GET",
url: "/api/v1/admin/jobs",
});
const retryResponse = await app.inject({
method: "POST",
url: "/api/v1/admin/jobs/job-1/retry",
headers: { "x-csrf-token": "csrf" },
});
assert.equal(listResponse.statusCode, 200); assert.equal(listResponse.statusCode, 200);
assert.equal(retryResponse.statusCode, 202); assert.equal(retryResponse.statusCode, 202);
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 1);
}); });
test("lists seller-scoped latest market valuations", async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
pricingService: {
async listValuations({ sellerId }) {
assert.equal(sellerId, "seller-1");
return [
{
id: "valuation-1",
inventoryItemId: "item-1",
valueMinor: 2500,
currency: "USD",
strategy: "MEDIAN",
confidence: "MEDIUM",
sourceCount: 2,
calculatedAt: new Date().toISOString(),
},
];
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: "GET",
url: "/api/v1/admin/valuations",
});
assert.equal(response.statusCode, 200);
const [valuation] = response.json().data;
assert.equal(valuation.inventoryItemId, "item-1");
assert.equal(valuation.valueMinor, 2500);
assert.equal(valuation.stale, false);
});
@@ -1,29 +1,40 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => { test("protects provider mapping writes and scopes mappings to the seller", async (testContext) => {
let received; let received;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
providerMappingService: { providerMappingService: {
async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; }, async create(input) {
async list() { return []; }, received = input;
async disable() { return { id: 'mapping-1', status: 'DISABLED' }; }, return { id: "mapping-1", ...input, status: "ACTIVE" };
},
async list() {
return [];
},
async disable() {
return { id: "mapping-1", status: "DISABLED" };
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/products/product-1/provider-mappings', url: "/api/v1/admin/products/product-1/provider-mappings",
headers: { 'x-csrf-token': 'csrf' }, headers: { "x-csrf-token": "csrf" },
payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' }, payload: {
providerId: "fixture",
mappingType: "PRODUCT",
externalIdentifier: "ext-1",
},
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(received.sellerId, 'seller-1'); assert.equal(received.sellerId, "seller-1");
assert.equal(response.json().data.providerId, 'fixture'); assert.equal(response.json().data.providerId, "fixture");
}); });
@@ -36,6 +36,10 @@ function createTestApp() {
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
status: 'PENDING', status: 'PENDING',
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
}; };
}, },
}, },
@@ -72,6 +76,9 @@ test('returns a public listing detail or a not-found response', async (testConte
assert.equal(detailResponse.statusCode, 200); assert.equal(detailResponse.statusCode, 200);
assert.equal('inventoryItemId' in detailResponse.json().data, false); assert.equal('inventoryItemId' in detailResponse.json().data, false);
assert.equal(detailResponse.json().data.sellerDisplayName, 'Pika Collectibles');
assert.equal(detailResponse.json().data.sellerDescription, 'Independent Pokémon collector.');
assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example');
assert.equal(missingResponse.statusCode, 404); assert.equal(missingResponse.statusCode, 404);
assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND'); assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND');
}); });
@@ -21,6 +21,10 @@ function createTestApp() {
name: 'Pikachu Plush', name: 'Pikachu Plush',
slug, slug,
description: 'A friendly plush.', description: 'A friendly plush.',
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: new Date('2026-09-07T00:00:00.000Z'), createdAt: new Date('2026-09-07T00:00:00.000Z'),
updatedAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'),
}; };
@@ -50,6 +54,11 @@ test('returns a public product without private seller identity', async (testCont
cardNumber: null, cardNumber: null,
eventName: null, eventName: null,
releaseYear: null, releaseYear: null,
tags: [],
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: '2026-09-07T00:00:00.000Z', createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z', updatedAt: '2026-09-07T00:00:00.000Z',
}); });
@@ -1,19 +1,24 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
test('returns public search results with pagination metadata', async (testContext) => { test("returns public search results with pagination metadata", async (testContext) => {
const app = buildApp({ const app = buildApp({
searchService: { searchService: {
async searchPublic(filters) { async searchPublic(filters) {
assert.equal(filters.page, 2); assert.equal(filters.page, 2);
assert.equal(filters.limit, 5); assert.equal(filters.limit, 5);
assert.equal(filters.includeSold, true); assert.equal(filters.includeSold, true);
assert.equal(filters.sort, 'price_low'); assert.equal(filters.sort, "price_low");
assert.equal(filters.conditionId, 'condition-1'); assert.equal(filters.conditionId, "condition-1");
assert.equal(filters.setName, "SV151");
assert.equal(filters.eventName, "Worlds");
assert.equal(filters.tag, "binder");
assert.equal(filters.status, "SOLD");
assert.equal(filters.newlyAdded, true);
return { return {
items: [{ id: 'listing-1', status: 'CLOSED' }], items: [{ id: "listing-1", status: "CLOSED" }],
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
}; };
}, },
@@ -22,8 +27,8 @@ test('returns public search results with pagination metadata', async (testContex
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: "GET",
url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1', url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1&setName=SV151&eventName=Worlds&tag=binder&status=SOLD&newlyAdded=true",
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
@@ -33,44 +38,71 @@ test('returns public search results with pagination metadata', async (testContex
total: 6, total: 6,
totalPages: 2, totalPages: 2,
}); });
assert.equal(response.json().data.items[0].id, 'listing-1'); assert.equal(response.json().data.items[0].id, "listing-1");
}); });
test('rejects malformed numeric search filters', async (testContext) => { test("rejects malformed numeric search filters", async (testContext) => {
const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } }); const app = buildApp({
searchService: {
async searchPublic() {
throw new Error("should not run");
},
},
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: "GET",
url: '/api/v1/public/search?minPriceMinor=12.5', url: "/api/v1/public/search?minPriceMinor=12.5",
}); });
assert.equal(response.statusCode, 400); assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER'); assert.equal(response.json().error.code, "INVALID_SEARCH_FILTER");
}); });
test('returns active public category options', async (testContext) => { test("returns active public category options", async (testContext) => {
const app = buildApp({ const app = buildApp({
catalogLookupRepository: { catalogLookupRepository: {
async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; }, async listCategories() {
return [{ id: "category-1", name: "Trading Cards" }];
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' }); const response = await app.inject({
method: "GET",
url: "/api/v1/public/lookups/categories",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]); assert.deepEqual(response.json().data, [
{ id: "category-1", name: "Trading Cards" },
]);
}); });
test('returns public condition options without category internals', async (testContext) => { test("returns public condition options without category internals", async (testContext) => {
const app = buildApp({ const app = buildApp({
catalogLookupRepository: { catalogLookupRepository: {
async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; }, async listConditions() {
return [
{
id: "condition-1",
name: "Near Mint",
code: "NM",
categoryId: "private-category",
},
];
},
}, },
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' }); const response = await app.inject({
method: "GET",
url: "/api/v1/public/lookups/conditions",
});
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]); assert.deepEqual(response.json().data, [
}); { id: "condition-1", name: "Near Mint", code: "NM" },
]);
});
+43 -39
View File
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test from 'node:test'; import test from "node:test";
import { buildApp } from '../../src/app/build-app.js'; import { buildApp } from "../../src/app/build-app.js";
const saleResult = { const saleResult = {
sale: { sale: {
id: 'sale-1', id: "sale-1",
sellerId: 'seller-1', sellerId: "seller-1",
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
currency: 'USD', currency: "USD",
platformFeesMinor: 0, platformFeesMinor: 0,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
shippingCostMinor: 0, shippingCostMinor: 0,
@@ -17,12 +17,12 @@ const saleResult = {
otherFeesMinor: 0, otherFeesMinor: 0,
}, },
saleItem: { saleItem: {
id: 'sale-item-1', id: "sale-item-1",
inventoryItemId: 'inventory-1', inventoryItemId: "inventory-1",
soldPriceMinor: 2499, soldPriceMinor: 2499,
costBasisMinor: 1200, costBasisMinor: 1200,
}, },
inventoryItem: { id: 'inventory-1', status: 'SOLD' }, inventoryItem: { id: "inventory-1", status: "SOLD" },
}; };
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
@@ -31,70 +31,74 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
authorizeUnsafeRequest, authorizeUnsafeRequest,
saleService: { saleService: {
async create(input) { async create(input) {
assert.equal(input.sellerId, 'seller-1'); assert.equal(input.sellerId, "seller-1");
return saleResult; return saleResult;
}, },
}, },
}); });
} }
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => { test("creates a sale for the authenticated seller with CSRF protection", async (testContext) => {
let csrfChecks = 0; let csrfChecks = 0;
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }), authenticateAdmin: async () => ({ sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => { csrfChecks += 1; }, authorizeUnsafeRequest: async () => {
csrfChecks += 1;
},
}); });
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { payload: {
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
costBasisMinor: 1200, costBasisMinor: 1200,
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 2499, soldPriceMinor: 2499,
}, },
}); });
assert.equal(response.statusCode, 201); assert.equal(response.statusCode, 201);
assert.equal(response.json().data.inventoryItem.status, 'SOLD'); assert.equal(response.json().data.inventoryItem.status, "SOLD");
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 1);
}); });
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => { test("rejects invalid sale requests and unauthenticated requests", async (testContext) => {
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
});
testContext.after(() => app.close()); testContext.after(() => app.close());
const invalidResponse = await app.inject({ const invalidResponse = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
payload: { payload: {
inventoryItemId: 'not-a-uuid', inventoryItemId: "not-a-uuid",
costBasisMinor: -1, costBasisMinor: -1,
saleDate: 'tomorrow', saleDate: "tomorrow",
soldPriceMinor: 0, soldPriceMinor: 0,
}, },
}); });
assert.equal(invalidResponse.statusCode, 400); assert.equal(invalidResponse.statusCode, 400);
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR'); assert.equal(invalidResponse.json().error.code, "VALIDATION_ERROR");
const unauthenticatedApp = createTestApp(); const unauthenticatedApp = createTestApp();
testContext.after(() => unauthenticatedApp.close()); testContext.after(() => unauthenticatedApp.close());
const unauthenticatedResponse = await unauthenticatedApp.inject({ const unauthenticatedResponse = await unauthenticatedApp.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales', url: "/api/v1/admin/sales",
payload: {}, payload: {},
}); });
assert.equal(unauthenticatedResponse.statusCode, 401); assert.equal(unauthenticatedResponse.statusCode, 401);
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED'); assert.equal(unauthenticatedResponse.json().error.code, "UNAUTHORIZED");
}); });
test('voids a sale with CSRF protection and actor identity', async (testContext) => { test("voids a sale with CSRF protection and actor identity", async (testContext) => {
let voidInput; let voidInput;
const app = buildApp({ const app = buildApp({
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }), authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
authorizeUnsafeRequest: async () => {}, authorizeUnsafeRequest: async () => {},
saleService: { saleService: {
async void(input) { async void(input) {
@@ -106,13 +110,13 @@ test('voids a sale with CSRF protection and actor identity', async (testContext)
testContext.after(() => app.close()); testContext.after(() => app.close());
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: "POST",
url: '/api/v1/admin/sales/sale-1/void', url: "/api/v1/admin/sales/sale-1/void",
headers: { 'x-csrf-token': 'csrf-token' }, headers: { "x-csrf-token": "csrf-token" },
payload: { reason: 'Duplicate entry' }, payload: { reason: "Duplicate entry" },
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.equal(voidInput.actorUserId, 'user-1'); assert.equal(voidInput.actorUserId, "user-1");
assert.equal(response.json().data.inventoryItem.status, 'SOLD'); assert.equal(response.json().data.inventoryItem.status, "SOLD");
}); });
@@ -1,3 +1,5 @@
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from 'node:test';
@@ -17,6 +19,7 @@ import {
products, products,
securityAuditEvents, securityAuditEvents,
sellers, sellers,
sellerSettings,
sessions, sessions,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from '../../src/infrastructure/database/schema.js';
@@ -190,6 +193,23 @@ test('finds only products with active inventory and a public listing', async ()
product.id, product.id,
); );
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
await database.db.insert(sellerSettings).values({ sellerId: seller.id, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, 'owner@example.test');
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: '' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, null);
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactPhone: ' ' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, null);
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
await database.db await database.db
.update(listings) .update(listings)
.set({ status: 'CLOSED' }) .set({ status: 'CLOSED' })
@@ -1,3 +1,5 @@
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from 'node:test';
@@ -12,6 +14,7 @@ import {
listings, listings,
products, products,
sellers, sellers,
sellerSettings,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from '../../src/infrastructure/database/schema.js';
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js';
@@ -148,6 +151,23 @@ test('searches public listings with pagination and hides sold items by default',
visible.items[0].id, visible.items[0].id,
); );
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
await database.db.insert(sellerSettings).values({ sellerId: product.sellerId, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, 'owner@example.test');
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: '' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, null);
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactPhone: ' ' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, null);
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
await database.db await database.db
.update(listings) .update(listings)
.set({ status: 'CLOSED' }) .set({ status: 'CLOSED' })
@@ -224,6 +244,17 @@ test('persists image metadata only for the owning seller and counts item images'
); );
}); });
test('enforces per-item image limits across concurrent inserts', async () => {
const { seller, inventoryItem } = await createInventoryFixture();
const image = (suffix) => ({ inventoryItemId: inventoryItem.id, storageKey: `inventory/${suffix}.jpg`, originalFilename: `${suffix}.jpg`, mimeType: 'image/jpeg', fileSize: 4, width: 2, height: 2, sortOrder: 0, isPrimary: false, processingStatus: 'PENDING' });
const results = await Promise.allSettled([
inventoryImageRepository.createForSellerWithLimit(image('a'), seller.id, 1),
inventoryImageRepository.createForSellerWithLimit(image('b'), seller.id, 1),
]);
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
assert.equal(results.filter((result) => result.status === 'rejected' && result.reason.code === 'IMAGE_COUNT_LIMIT_REACHED').length, 1);
});
test('reads and updates listings only through the owning seller scope', async () => { test('reads and updates listings only through the owning seller scope', async () => {
const { seller, category, condition, inventoryItem } = await createInventoryFixture(); const { seller, category, condition, inventoryItem } = await createInventoryFixture();
@@ -253,3 +284,65 @@ test('reads and updates listings only through the owning seller scope', async ()
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED'); const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
assert.equal(archivedListing.status, 'ARCHIVED'); assert.equal(archivedListing.status, 'ARCHIVED');
}); });
async function createImageOrderFixture() {
const { seller, inventoryItem } = await createInventoryFixture();
const images = [];
for (let index = 0; index < 3; index += 1) {
images.push(await inventoryImageRepository.createForSeller({
inventoryItemId: inventoryItem.id, storageKey: `move-${index}.jpg`, originalFilename: `move-${index}.jpg`,
mimeType: 'image/jpeg', fileSize: 100, width: 10, height: 10, sortOrder: 0,
isPrimary: index === 0,
}, seller.id));
}
return { seller, inventoryItem, images: await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id) };
}
test('moves images atomically with tied positions, ownership checks and boundary no-ops', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
const read = () => inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.equal(await inventoryImageRepository.moveForSeller(images[0].id, 'down', '00000000-0000-0000-0000-000000000000'), null);
assert.equal(await inventoryImageRepository.moveForSeller('00000000-0000-0000-0000-000000000000', 'down', seller.id), null);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id);
const moved = await read();
assert.deepEqual(moved.map(({ id }) => id), [images[1].id, images[0].id, images[2].id]);
assert.deepEqual(moved.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
assert.equal(moved.find(({ id }) => id === images[0].id).isPrimary, images[0].isPrimary);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual((await read()).map(({ id }) => id), images.map(({ id }) => id));
});
test('rolls back every position if an image move fails after its first write', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
let writes = 0;
const failingRepository = createInventoryImageRepository({
transaction: (action) => database.db.transaction((tx) => action(new Proxy(tx, {
get(target, property) {
if (property === 'update') return (...args) => {
writes += 1;
if (writes === 2) throw new Error('Injected second write failure');
return target.update(...args);
};
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
},
}))),
});
await assert.rejects(() => failingRepository.moveForSeller(images[0].id, 'down', seller.id), /Injected second write failure/);
assert.equal(writes, 2);
assert.deepEqual(await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id), images);
});
test('serializes concurrent moves within the same gallery', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
await Promise.all([
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
]);
const result = await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.deepEqual(result.map(({ id }) => id), [images[1].id, images[2].id, images[0].id]);
assert.deepEqual(result.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
});
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from "node:test";
import { sql } from 'drizzle-orm'; import { sql } from "drizzle-orm";
import { createDatabase } from '../../src/infrastructure/database/client.js'; import { createDatabase } from "../../src/infrastructure/database/client.js";
import { import {
categories, categories,
conditionProfiles, conditionProfiles,
@@ -13,76 +13,104 @@ import {
securityAuditEvents, securityAuditEvents,
sellers, sellers,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from "../../src/infrastructure/database/schema.js";
import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js'; import { createAcquisitionRepository } from "../../src/modules/acquisitions/repositories/acquisition-repository.js";
import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js'; import { createPricingRepository } from "../../src/modules/pricing/repositories/pricing-repository.js";
import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js'; import { createPricingRefreshJobRepository } from "../../src/modules/pricing/repositories/pricing-refresh-job-repository.js";
import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js'; import { createProviderMappingRepository } from "../../src/modules/pricing/repositories/provider-mapping-repository.js";
import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js'; import { createSaleRepository } from "../../src/modules/sales/repositories/sale-repository.js";
const connectionString = process.env.TEST_DATABASE_URL; const connectionString = process.env.TEST_DATABASE_URL;
if (!connectionString) { if (!connectionString) {
throw new Error('TEST_DATABASE_URL is required to run integration tests.'); throw new Error("TEST_DATABASE_URL is required to run integration tests.");
} }
if (!new URL(connectionString).pathname.endsWith('_test')) { if (!new URL(connectionString).pathname.endsWith("_test")) {
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".'); throw new Error(
'TEST_DATABASE_URL must reference a database whose name ends in "_test".',
);
} }
const database = createDatabase(connectionString); const database = createDatabase(connectionString);
const acquisitionRepository = createAcquisitionRepository(database.db); const acquisitionRepository = createAcquisitionRepository(database.db);
const pricingRepository = createPricingRepository(database.db); const pricingRepository = createPricingRepository(database.db);
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db); const pricingRefreshJobRepository = createPricingRefreshJobRepository(
database.db,
);
const providerMappingRepository = createProviderMappingRepository(database.db); const providerMappingRepository = createProviderMappingRepository(database.db);
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
async function createFixture() { async function createFixture(suffix = "") {
const [user] = await database.db.insert(users).values({ const fixtureName = suffix ? `transactions-${suffix}` : "transactions";
email: 'transactions@example.test', const [user] = await database.db
passwordHash: 'not-a-real-password', .insert(users)
}).returning(); .values({
const [seller] = await database.db.insert(sellers).values({ email: `${fixtureName}@example.test`,
userId: user.id, passwordHash: "not-a-real-password",
displayName: 'Transactions Owner', })
}).returning(); .returning();
const [category] = await database.db.insert(categories).values({ const [seller] = await database.db
name: 'Transactions Cards', .insert(sellers)
slug: 'transactions-cards', .values({
}).returning(); userId: user.id,
const [profile] = await database.db.insert(conditionProfiles).values({ displayName: `${fixtureName} Owner`,
categoryId: category.id, })
name: 'Card', .returning();
}).returning(); const [category] = await database.db
const [condition] = await database.db.insert(conditions).values({ .insert(categories)
conditionProfileId: profile.id, .values({
name: 'Near Mint', name: `${fixtureName} Cards`,
code: 'NM', slug: `${fixtureName}-cards`,
qualityRank: 1, })
}).returning(); .returning();
const [product] = await database.db.insert(products).values({ const [profile] = await database.db
sellerId: seller.id, .insert(conditionProfiles)
categoryId: category.id, .values({
name: 'Integration Card', categoryId: category.id,
slug: 'integration-card', name: "Card",
}).returning(); })
const inventory = await database.db.insert(inventoryItems).values([ .returning();
{ productId: product.id, conditionId: condition.id }, const [condition] = await database.db
{ productId: product.id, conditionId: condition.id }, .insert(conditions)
]).returning(); .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: `${fixtureName} Card`,
slug: `${fixtureName}-card`,
})
.returning();
const inventory = await database.db
.insert(inventoryItems)
.values([
{ productId: product.id, conditionId: condition.id },
{ productId: product.id, conditionId: condition.id },
])
.returning();
return { user, seller, product, condition, inventory }; return { user, seller, product, condition, inventory };
} }
beforeEach(async () => { beforeEach(async () => {
await database.db.execute(sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`); await database.db.execute(
sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
);
}); });
after(async () => { after(async () => {
await database.close(); await database.close();
}); });
test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => { test("persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally", async () => {
const { user, seller, product, condition, inventory } = await createFixture(); const { user, seller, product, condition, inventory } = await createFixture();
const [firstItem, secondItem] = inventory; const [firstItem, secondItem] = inventory;
@@ -90,47 +118,50 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
sellerId: seller.id, sellerId: seller.id,
acquisition: { acquisition: {
inventoryItemId: firstItem.id, inventoryItemId: firstItem.id,
method: 'PURCHASE', method: "PURCHASE",
costMinor: 1000, costMinor: 1000,
currency: 'USD', currency: "USD",
shippingMinor: 100, shippingMinor: 100,
taxMinor: 50, taxMinor: 50,
otherFeesMinor: 0, otherFeesMinor: 0,
estimatedTradeValueMinor: 0, estimatedTradeValueMinor: 0,
}, },
}); });
assert.equal(acquisition.status, 'CREATED'); assert.equal(acquisition.status, "CREATED");
for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) { for (const [item, price] of [
[firstItem, 2500],
[secondItem, 3000],
]) {
const observation = await pricingRepository.addObservationForSeller({ const observation = await pricingRepository.addObservationForSeller({
sellerId: seller.id, sellerId: seller.id,
observation: { observation: {
productId: product.id, productId: product.id,
inventoryItemId: item.id, inventoryItemId: item.id,
providerId: 'integration', providerId: "integration",
priceMinor: price, priceMinor: price,
currency: 'USD', currency: "USD",
sourceIdentifier: `source-${item.id}`, sourceIdentifier: `source-${item.id}`,
conditionId: condition.id, conditionId: condition.id,
}, },
}); });
assert.equal(observation.status, 'CREATED'); assert.equal(observation.status, "CREATED");
} }
const valuation = await pricingRepository.calculateForSeller({ const valuation = await pricingRepository.calculateForSeller({
sellerId: seller.id, sellerId: seller.id,
inventoryItemId: firstItem.id, inventoryItemId: firstItem.id,
}); });
assert.equal(valuation.status, 'CALCULATED'); assert.equal(valuation.status, "CALCULATED");
assert.equal(valuation.valuation.valueMinor, 2500); assert.equal(valuation.valuation.valueMinor, 2500);
assert.equal(valuation.valuation.sourceCount, 1); assert.equal(valuation.valuation.sourceCount, 1);
const sale = await saleRepository.createForInventoryItems({ const sale = await saleRepository.createForInventoryItems({
sale: { sale: {
sellerId: seller.id, sellerId: seller.id,
saleDate: '2026-09-09', saleDate: "2026-09-09",
soldPriceMinor: 5500, soldPriceMinor: 5500,
currency: 'USD', currency: "USD",
platformFeesMinor: 200, platformFeesMinor: 200,
paymentProcessingFeesMinor: 0, paymentProcessingFeesMinor: 0,
shippingCostMinor: 0, shippingCostMinor: 0,
@@ -138,58 +169,130 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
otherFeesMinor: 0, otherFeesMinor: 0,
}, },
items: [ items: [
{ inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 }, {
{ inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 }, inventoryItemId: firstItem.id,
soldPriceMinor: 2500,
costBasisMinor: 1150,
},
{
inventoryItemId: secondItem.id,
soldPriceMinor: 3000,
costBasisMinor: 900,
},
], ],
}); });
assert.equal(sale.status, 'CREATED'); assert.equal(sale.status, "CREATED");
assert.equal(sale.saleItems.length, 2); assert.equal(sale.saleItems.length, 2);
assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']); assert.deepEqual(
sale.inventoryItems.map(({ status }) => status),
["SOLD", "SOLD"],
);
const voided = await saleRepository.voidForSeller({ const voided = await saleRepository.voidForSeller({
saleId: sale.sale.id, saleId: sale.sale.id,
sellerId: seller.id, sellerId: seller.id,
actorUserId: user.id, actorUserId: user.id,
reason: 'Integration correction', reason: "Integration correction",
}); });
assert.equal(voided.status, 'VOIDED'); assert.equal(voided.status, "VOIDED");
assert.equal(voided.sale.status, 'VOIDED'); assert.equal(voided.sale.status, "VOIDED");
assert.equal(voided.saleItems.length, 2); assert.equal(voided.saleItems.length, 2);
const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`); const [auditEvent] = await database.db
.select()
.from(securityAuditEvents)
.where(sql`event = 'SALE_VOIDED'`);
assert.equal(auditEvent.userId, user.id); assert.equal(auditEvent.userId, user.id);
}); });
test('claims and completes a durable pricing refresh job', async () => { test("claims and completes a durable pricing refresh job", async () => {
const { user, seller, product, condition, inventory } = await createFixture(); const { user, seller, product, condition, inventory } = await createFixture();
const [job] = await pricingRefreshJobRepository.create({ const [job] = await pricingRefreshJobRepository
sellerId: seller.id, .create({
inventoryItemId: inventory[0].id, sellerId: seller.id,
productId: product.id, inventoryItemId: inventory[0].id,
conditionId: condition.id, productId: product.id,
providerId: 'integration', conditionId: condition.id,
}).then((created) => [created]); providerId: "integration",
})
.then((created) => [created]);
const claimed = await pricingRefreshJobRepository.claimNext(); const claimed = await pricingRefreshJobRepository.claimNext();
assert.equal(claimed.id, job.id); assert.equal(claimed.id, job.id);
assert.equal(claimed.status, 'PROCESSING'); assert.equal(claimed.status, "PROCESSING");
const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 }); const completed = await pricingRefreshJobRepository.complete(
assert.equal(completed.status, 'COMPLETED'); job.id,
[{ priceMinor: 2500 }],
{ valueMinor: 2500 },
);
assert.equal(completed.status, "COMPLETED");
assert.deepEqual(completed.valuation, { valueMinor: 2500 }); assert.deepEqual(completed.valuation, { valueMinor: 2500 });
assert.equal(user.email, 'transactions@example.test'); assert.equal(user.email, "transactions@example.test");
}); });
test('persists and disables seller-owned provider mappings', async () => { test("purges completed pricing jobs across sellers before the cutoff", async () => {
const first = await createFixture();
const second = await createFixture("second");
const jobs = await Promise.all(
[first, second].map(({ seller, product, condition, inventory }) =>
pricingRefreshJobRepository.create({
sellerId: seller.id,
inventoryItemId: inventory[0].id,
productId: product.id,
conditionId: condition.id,
providerId: "integration",
}),
),
);
for (const job of jobs) {
await pricingRefreshJobRepository.claimNext();
await pricingRefreshJobRepository.complete(
job.id,
[{ priceMinor: 2500 }],
{ valueMinor: 2500 },
);
}
const deleted = await pricingRefreshJobRepository.purgeCompletedBefore(
new Date(Date.now() + 1000),
);
assert.equal(deleted, 2);
assert.equal(
(await pricingRefreshJobRepository.listForSeller(first.seller.id)).length,
0,
);
assert.equal(
(await pricingRefreshJobRepository.listForSeller(second.seller.id)).length,
0,
);
});
test("persists and disables seller-owned provider mappings", async () => {
const { seller, product } = await createFixture(); const { seller, product } = await createFixture();
const created = await providerMappingRepository.createForSeller({ const created = await providerMappingRepository.createForSeller(
productId: product.id, {
providerId: 'integration', productId: product.id,
mappingType: 'PRODUCT', providerId: "integration",
externalIdentifier: 'catalog-1', mappingType: "PRODUCT",
}, seller.id); externalIdentifier: "catalog-1",
assert.equal(created.status, 'CREATED'); },
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1); seller.id,
const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id); );
assert.equal(disabled.status, 'DISABLED'); assert.equal(created.status, "CREATED");
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0); assert.equal(
(await providerMappingRepository.listForSeller(product.id, seller.id))
.length,
1,
);
const disabled = await providerMappingRepository.disableForSeller(
created.mapping.id,
seller.id,
);
assert.equal(disabled.status, "DISABLED");
assert.equal(
(await providerMappingRepository.listForSeller(product.id, seller.id))
.length,
0,
);
}); });
+7
View File
@@ -1,4 +1,11 @@
import { setup } from '@storybook/vue3';
import '../src/assets/styles/global.scss'; import '../src/assets/styles/global.scss';
import { storybookRouter } from './router.js';
setup((app) => {
app.use(storybookRouter);
});
export const globalTypes = { export const globalTypes = {
theme: { theme: {
+24
View File
@@ -0,0 +1,24 @@
import { createMemoryHistory, createRouter } from 'vue-router';
const stub = { render: () => null };
// Mirrors the named routes in src/router/index.js so RouterLink resolution
// works in isolated stories without importing the real app router/guards.
export const storybookRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'catalog', component: stub },
{ path: '/listings/:id', name: 'listing-detail', component: stub },
{ path: '/products/:slug', name: 'product-detail', component: stub },
{ path: '/login', name: 'login', component: stub },
{ path: '/admin', name: 'admin-home', component: stub },
{ path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: stub },
{ path: '/admin/inventory', name: 'admin-inventory', component: stub },
{ path: '/admin/listings', name: 'admin-listings', component: stub },
{ path: '/admin/sales', name: 'admin-sales', component: stub },
{ path: '/admin/acquisitions', name: 'admin-acquisitions', component: stub },
{ path: '/admin/settings', name: 'admin-settings', component: stub },
{ path: '/admin/saved-views', name: 'admin-saved-views', component: stub },
{ path: '/:pathMatch(.*)*', name: 'storybook-catch-all', component: stub },
],
});
+12
View File
@@ -0,0 +1,12 @@
// Forces a narrow viewport for stories that specifically exercise mobile-only
// CSS breakpoints, since @storybook/addon-viewport does not affect the
// headless iframe size that @storybook/test-runner navigates to directly.
export default {
async preVisit(page, context) {
if (context.id?.includes('--enlarged')) {
await page.setViewportSize({ width: 640, height: 900 });
} else if (context.id?.includes('--mobile')) {
await page.setViewportSize({ width: 320, height: 900 });
}
},
};
@@ -16,6 +16,7 @@
--color-success-500: #12a150; --color-success-500: #12a150;
--color-warning-500: #d88a00; --color-warning-500: #d88a00;
--color-danger-500: #e5484d; --color-danger-500: #e5484d;
--color-danger-solid: #b4232b;
--color-info-500: #1683c4; --color-info-500: #1683c4;
--color-success-text: #08783a; --color-success-text: #08783a;
--color-warning-text: #8a5700; --color-warning-text: #8a5700;
@@ -91,6 +92,9 @@
} }
[data-theme='dark'] { [data-theme='dark'] {
--color-success-text: #63d991;
--color-warning-text: #ffc766;
--color-danger-text: #ff9297;
color-scheme: dark; color-scheme: dark;
--color-background: #0b0d10; --color-background: #0b0d10;
--color-surface: #11161d; --color-surface: #11161d;
+13 -4
View File
@@ -9,6 +9,7 @@ button, input, select, textarea { font: inherit; }
button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } } button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } }
button { cursor: pointer; } button { cursor: pointer; }
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); } h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; border: 0; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
.catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); } .catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); }
.catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; } .catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; }
@@ -23,7 +24,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.results-heading span { color: var(--color-text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; } .results-heading span { color: var(--color-text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-top: var(--space-6); } .listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-top: var(--space-6); }
.detail-page { width: min(920px, calc(100% - var(--space-8))); margin: 0 auto; padding: var(--space-12) 0 var(--space-24); } .detail-page { width: min(920px, calc(100% - var(--space-8))); margin: 0 auto; padding: var(--space-12) 0 var(--space-24); }
.detail-back { color: var(--color-interactive); font-weight: var(--weight-bold); text-decoration: none; } .detail-back { color: var(--color-interactive-text); font-weight: var(--weight-bold); text-decoration: none; }
.detail-content { max-width: 680px; padding-top: var(--space-16); } .detail-content { max-width: 680px; padding-top: var(--space-16); }
.detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); } .detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); }
.detail-price { color: var(--color-text); font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; } .detail-price { color: var(--color-text); font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
@@ -33,15 +34,18 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.product-facts dt { color: var(--color-text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: 0.1em; text-transform: uppercase; } .product-facts dt { color: var(--color-text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: 0.1em; text-transform: uppercase; }
.product-facts dd { margin: 0; } .product-facts dd { margin: 0; }
.catalog-state { padding: var(--space-12) 0; color: var(--color-text-muted); } .catalog-state { padding: var(--space-12) 0; color: var(--color-text-muted); }
.catalog-state--error { color: var(--color-danger-500); } .catalog-state--error { color: var(--color-danger-text); }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-6); margin-top: var(--space-8); color: var(--color-text-muted); font-size: var(--text-sm); } .pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-6); margin-top: var(--space-8); color: var(--color-text-muted); font-size: var(--text-sm); }
.pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive); } .pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive-text); }
.pagination button:disabled { cursor: not-allowed; opacity: 0.45; } .pagination button:disabled { cursor: not-allowed; opacity: 0.45; }
.showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); } .showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); }
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); } .auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); }
.auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); } .auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); }
.auth-panel h1 { font-size: var(--text-2xl); } .auth-panel h1 { font-size: var(--text-2xl); }
.skip-link { position: absolute; left: var(--space-4); top: var(--space-2); z-index: 10; transform: translateY(-200%); background: var(--color-surface); color: var(--color-interactive-text); padding: var(--space-2) var(--space-4); border-radius: var(--radius-sm); }
.skip-link:focus { transform: translateY(0); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); } .admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); }
.admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); } .admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); }
.admin-sidebar h1 { font-size: var(--text-xl); } .admin-sidebar h1 { font-size: var(--text-xl); }
@@ -53,6 +57,9 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-page { max-width: 880px; } .admin-page { max-width: 880px; }
.admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; } .admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; }
.admin-workspace-grid form { display: grid; gap: var(--space-3); } .admin-workspace-grid form { display: grid; gap: var(--space-3); }
.admin-wizard { display: grid; gap: var(--space-4); margin: var(--space-8) 0; }
.admin-wizard__nav { display: flex; justify-content: space-between; gap: var(--space-3); }
.admin-wizard > [role="region"]:focus { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
.admin-list { margin-top: var(--space-8); } .admin-list { margin-top: var(--space-8); }
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); } .admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } .admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
@@ -75,7 +82,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); } .button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); }
.button--primary { background: var(--color-interactive); color: var(--color-on-interactive); } .button--primary { background: var(--color-interactive); color: var(--color-on-interactive); }
.button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); } .button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
.button--danger { background: var(--color-danger-text); color: var(--color-white); } .button--danger { background: var(--color-danger-solid); color: var(--color-white); }
.button:disabled { cursor: not-allowed; opacity: 0.45; } .button:disabled { cursor: not-allowed; opacity: 0.45; }
.swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); } .swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); }
.swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); } .swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
@@ -120,3 +127,5 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-shell__content { padding: var(--space-6); } .admin-shell__content { padding: var(--space-6); }
.admin-workspace-grid { grid-template-columns: 1fr; } .admin-workspace-grid { grid-template-columns: 1fr; }
} }
.public-page-focus:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: -3px; }
@@ -0,0 +1,18 @@
.catalog-sort-select {
display: grid;
gap: var(--space-1);
}
.catalog-sort-select span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.catalog-sort-select select {
min-height: 44px;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
color: var(--color-text);
}
@@ -0,0 +1,7 @@
import CatalogSortSelect from './CatalogSortSelect.vue';
export default { title: 'Atoms/CatalogSortSelect', component: CatalogSortSelect };
export const RecentlyAdded = { args: { modelValue: 'recent' } };
export const NameAscending = { args: { modelValue: 'name_asc' } };
export const MarketValueDescending = { args: { modelValue: 'market_high' } };
@@ -0,0 +1,30 @@
<script setup>
defineProps({
modelValue: { type: String, required: true },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-sort-select">
<span>Sort</span>
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option value="recent">Recently added</option>
<option value="updated">Recently updated</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="price_low">Price: low to high</option>
<option value="price_high">Price: high to low</option>
<option value="market_low">Market value: low to high</option>
<option value="market_high">Market value: high to low</option>
</select>
</label>
</template>
<style scoped lang="scss">
@use './CatalogSortSelect.scss';
</style>
@@ -0,0 +1,18 @@
.catalog-text-filter {
display: grid;
gap: var(--space-1);
}
.catalog-text-filter span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.catalog-text-filter input {
min-height: 44px;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
color: var(--color-text);
}
@@ -0,0 +1,6 @@
import CatalogTextFilter from './CatalogTextFilter.vue';
export default { title: 'Atoms/CatalogTextFilter', component: CatalogTextFilter };
export const Set = { args: { label: 'Set', modelValue: '', placeholder: 'SV151' } };
export const Tag = { args: { label: 'Tag', modelValue: 'binder' } };
@@ -0,0 +1,25 @@
<script setup>
defineProps({
label: { type: String, required: true },
modelValue: { type: String, required: true },
placeholder: { type: String, default: '' },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-text-filter">
<span>{{ label }}</span>
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
</label>
</template>
<style scoped lang="scss">
@use './CatalogTextFilter.scss';
</style>
@@ -0,0 +1,14 @@
.catalog-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
color: var(--color-text);
font-size: var(--text-sm);
}
.catalog-toggle input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--color-interactive);
}
@@ -0,0 +1,6 @@
import CatalogToggle from './CatalogToggle.vue';
export default { title: 'Atoms/CatalogToggle', component: CatalogToggle };
export const NewlyAdded = { args: { label: 'Newly added', modelValue: false } };
export const IncludeSold = { args: { label: 'Include sold', modelValue: true } };

Some files were not shown because too many files have changed in this diff Show More