Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3132e80646 | ||
|
|
a81f882831 | ||
|
|
6840c9e6ba | ||
|
|
3dbb2e8001 | ||
|
|
312b1e5bbc | ||
|
|
cc45a4f119 | ||
|
|
7c6e00fca3 | ||
|
|
255848fcaa | ||
|
|
f1190fa806 | ||
|
|
7786671c00 | ||
|
|
0f04ef45fe | ||
|
|
38406f7c60 | ||
|
|
e3db8df92e | ||
|
|
fef7c0a339 | ||
|
|
4217009384 | ||
|
|
bb8499e10c | ||
|
|
18a99595e5 | ||
|
|
98d2adac17 | ||
|
|
eb43cef872 | ||
|
|
f54223966c | ||
|
|
e6510c0317 | ||
|
|
cce395baf6 | ||
|
|
0e81fc4022 | ||
|
|
72e9a79b67 | ||
|
|
c5a07b2b5a | ||
|
|
d321d8c679 | ||
|
|
9a790ce294 | ||
|
|
73c430b8a5 | ||
|
|
a02965de4a | ||
|
|
94c37250d1 | ||
|
|
e630a89e8d | ||
|
|
54a8d87293 | ||
|
|
6b528350c0 | ||
|
|
c85693c919 | ||
|
|
934aad2afd | ||
|
|
1fa4bb1c7a | ||
|
|
6c99ffaeba | ||
|
|
54be51ed5d | ||
|
|
c9f6a58516 | ||
|
|
7d20aedd14 | ||
|
|
ae1605c82d | ||
|
|
82b012faa1 | ||
|
|
ff6f3359aa | ||
|
|
13702bb3ef | ||
|
|
7ad5635900 | ||
|
|
3e29ca0529 | ||
|
|
8ff547feaa | ||
|
|
ffb86ce21a | ||
|
|
81705af6e3 | ||
|
|
c9ad3a11af | ||
|
|
673701fa7a | ||
|
|
b036092775 | ||
|
|
0008af9f2c | ||
|
|
96d3bf831e | ||
|
|
ea6a839b69 | ||
|
|
1e270f6184 | ||
|
|
e069dcaa40 | ||
|
|
0cd1b9ff2c | ||
|
|
fce30513d4 | ||
|
|
4f0e3d89f6 | ||
|
|
f837324cf5 | ||
|
|
ffd239d27c | ||
|
|
ec322df25c | ||
|
|
ad8f675f5e | ||
|
|
7c4885cf65 | ||
|
|
abb0b64217 | ||
|
|
cf61ec3c2c | ||
|
|
24f51212e5 | ||
|
|
d6b4dbec5f | ||
|
|
7c9518923d | ||
|
|
d3821e9c76 | ||
|
|
099ec2fe72 | ||
|
|
e0d231facc | ||
|
|
12eced8b19 | ||
|
|
aaa9e2a370 | ||
|
|
79df9d5c88 | ||
|
|
76e40d7289 | ||
|
|
660690bf34 | ||
|
|
b8f089a5d9 |
@@ -9,6 +9,8 @@ PRICING_FIXTURE_PROVIDER_ENABLED=false
|
||||
PRICING_HTTP_PROVIDER_ID=http-provider
|
||||
PRICING_HTTP_PROVIDER_ENDPOINT=
|
||||
PRICING_HTTP_PROVIDER_TOKEN=
|
||||
PRICING_JOB_RETENTION_DAYS=30
|
||||
PRICING_JOB_CLEANUP_INTERVAL_MS=3600000
|
||||
PASSWORD_RESET_URL=http://localhost:5173/reset-password
|
||||
SMTP_FROM=no-reply@example.test
|
||||
SMTP_URL=smtp://localhost:1025
|
||||
|
||||
@@ -47,6 +47,19 @@ jobs:
|
||||
DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- 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
|
||||
env:
|
||||
TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test
|
||||
|
||||
@@ -132,7 +132,7 @@ With `npm run dev` running, configured local admin credentials, and demo lookup
|
||||
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
|
||||
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import multipart from '@fastify/multipart';
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import multipart from "@fastify/multipart";
|
||||
|
||||
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
|
||||
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
|
||||
import { registerAdminLookupRoutes } from '../modules/inventory/routes/admin-lookup-routes.js';
|
||||
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
|
||||
import { registerPublicInventoryRoutes } from '../modules/inventory/routes/public-inventory-routes.js';
|
||||
import { registerPublicImageRoutes } from '../modules/inventory/routes/public-image-routes.js';
|
||||
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
|
||||
import { registerPublicListingRoutes } from '../modules/listings/routes/public-listing-routes.js';
|
||||
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
|
||||
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
||||
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
||||
import { registerPublicLookupRoutes } from '../modules/inventory/routes/public-lookup-routes.js';
|
||||
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
||||
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js';
|
||||
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js';
|
||||
import { registerAdminPricingRoutes } from '../modules/pricing/routes/admin-pricing-routes.js';
|
||||
import { registerAdminProviderMappingRoutes } from '../modules/pricing/routes/admin-provider-mapping-routes.js';
|
||||
import { registerAuthRoutes } from "../modules/auth/routes/auth-routes.js";
|
||||
import { registerAdminInventoryRoutes } from "../modules/inventory/routes/admin-inventory-routes.js";
|
||||
import { registerAdminLookupRoutes } from "../modules/inventory/routes/admin-lookup-routes.js";
|
||||
import { registerAdminImageRoutes } from "../modules/inventory/routes/admin-image-routes.js";
|
||||
import { registerPublicInventoryRoutes } from "../modules/inventory/routes/public-inventory-routes.js";
|
||||
import { registerPublicImageRoutes } from "../modules/inventory/routes/public-image-routes.js";
|
||||
import { registerAdminListingRoutes } from "../modules/listings/routes/admin-listing-routes.js";
|
||||
import { registerPublicListingRoutes } from "../modules/listings/routes/public-listing-routes.js";
|
||||
import { registerAdminProductRoutes } from "../modules/products/routes/admin-product-routes.js";
|
||||
import { registerPublicProductRoutes } from "../modules/products/routes/public-product-routes.js";
|
||||
import { registerPublicSearchRoutes } from "../modules/search/routes/public-search-routes.js";
|
||||
import { registerPublicSellerRoutes } from "../modules/sellers/routes/public-seller-routes.js";
|
||||
import { registerPublicLookupRoutes } from "../modules/inventory/routes/public-lookup-routes.js";
|
||||
import { registerAdminSaleRoutes } from "../modules/sales/routes/admin-sale-routes.js";
|
||||
import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/admin-acquisition-routes.js";
|
||||
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
||||
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
||||
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
||||
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) {
|
||||
const app = Fastify();
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const message = statusCode >= 500 ? 'Internal server error.' : error.message;
|
||||
const message =
|
||||
statusCode >= 500 ? "Internal server error." : error.message;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
request.log.error(error);
|
||||
@@ -34,86 +38,98 @@ export function buildApp(dependencies) {
|
||||
|
||||
return reply.code(statusCode).send({
|
||||
error: {
|
||||
code: error.code ?? 'INTERNAL_SERVER_ERROR',
|
||||
code: error.code ?? "INTERNAL_SERVER_ERROR",
|
||||
message,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ data: { status: 'ok' } }));
|
||||
app.get("/health", async () => ({ data: { status: "ok" } }));
|
||||
app.register(cookie);
|
||||
app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
||||
app.register(registerAuthRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/auth',
|
||||
prefix: "/api/v1/auth",
|
||||
});
|
||||
app.register(registerAdminProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminLookupRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminSaleRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminAcquisitionRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminDashboardRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminPricingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminProviderMappingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(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, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicSellerRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
app.register(registerPublicLookupRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1',
|
||||
prefix: "/api/v1",
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
+152
-75
@@ -1,85 +1,140 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url)) });
|
||||
dotenv.config({
|
||||
path: fileURLToPath(new URL("../../../../.env", import.meta.url)),
|
||||
});
|
||||
|
||||
import { buildApp } from './build-app.js';
|
||||
import { createDatabase } from '../infrastructure/database/client.js';
|
||||
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js';
|
||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||
import { createMfaService } from '../modules/auth/services/mfa-service.js';
|
||||
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js';
|
||||
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js';
|
||||
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js';
|
||||
import { createUserRepository } from '../modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js';
|
||||
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js';
|
||||
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js';
|
||||
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createCatalogLookupRepository } from '../modules/inventory/repositories/catalog-lookup-repository.js';
|
||||
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
|
||||
import { createPublicInventoryService } from '../modules/inventory/services/public-inventory-service.js';
|
||||
import { createPublicImageService } from '../modules/inventory/services/public-image-service.js';
|
||||
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createImageDerivativeJobRepository } from '../modules/inventory/repositories/image-derivative-job-repository.js';
|
||||
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
|
||||
import { createLocalImageStorage } from '../infrastructure/images/local-image-storage.js';
|
||||
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js';
|
||||
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js';
|
||||
import { createListingService } from '../modules/listings/services/listing-service.js';
|
||||
import { createPublicListingService } from '../modules/listings/services/public-listing-service.js';
|
||||
import { createProductRepository } from '../modules/products/repositories/product-repository.js';
|
||||
import { createProductService } from '../modules/products/services/product-service.js';
|
||||
import { createPublicProductService } from '../modules/products/services/public-product-service.js';
|
||||
import { createSearchService } from '../modules/search/services/search-service.js';
|
||||
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
||||
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
||||
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js';
|
||||
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
||||
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js';
|
||||
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js';
|
||||
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js';
|
||||
import { createPricingRepository } from '../modules/pricing/repositories/pricing-repository.js';
|
||||
import { createPricingService } from '../modules/pricing/services/pricing-service.js';
|
||||
import { createPricingRefreshJobRepository } from '../modules/pricing/repositories/pricing-refresh-job-repository.js';
|
||||
import { createProviderMappingRepository } from '../modules/pricing/repositories/provider-mapping-repository.js';
|
||||
import { createProviderMappingService } from '../modules/pricing/services/provider-mapping-service.js';
|
||||
import { createPricingProviderRegistry } from '../modules/pricing/providers/pricing-provider.js';
|
||||
import { createFixturePricingProvider } from '../modules/pricing/providers/fixture-pricing-provider.js';
|
||||
import { buildApp } from "./build-app.js";
|
||||
import { createDatabase } from "../infrastructure/database/client.js";
|
||||
import { resolveProjectImageStorageRoot } from "../infrastructure/images/local-image-storage.js";
|
||||
import { createPasswordResetNotifier } from "../infrastructure/email/password-reset-notifier.js";
|
||||
import { createRedisConnection } from "../infrastructure/redis/client.js";
|
||||
import { createAuthenticationService } from "../modules/auth/services/authentication-service.js";
|
||||
import { createMfaService } from "../modules/auth/services/mfa-service.js";
|
||||
import { createSecretProtector } from "../modules/auth/domain/secret-protection.js";
|
||||
import { createMfaMethodRepository } from "../modules/auth/repositories/mfa-method-repository.js";
|
||||
import { createSecurityAuditRepository } from "../modules/auth/repositories/security-audit-repository.js";
|
||||
import { createSessionRepository } from "../modules/auth/repositories/session-repository.js";
|
||||
import { createUserRepository } from "../modules/auth/repositories/user-repository.js";
|
||||
import { createPasswordResetRepository } from "../modules/auth/repositories/password-reset-repository.js";
|
||||
import { createRedisLoginRateLimiter } from "../modules/auth/services/login-rate-limiter.js";
|
||||
import { createPasswordRecoveryService } from "../modules/auth/services/password-recovery-service.js";
|
||||
import { createInventoryItemRepository } from "../modules/inventory/repositories/inventory-item-repository.js";
|
||||
import { createCatalogLookupRepository } from "../modules/inventory/repositories/catalog-lookup-repository.js";
|
||||
import { createInventoryItemService } from "../modules/inventory/services/inventory-item-service.js";
|
||||
import { createPublicInventoryService } from "../modules/inventory/services/public-inventory-service.js";
|
||||
import { createPublicImageService } from "../modules/inventory/services/public-image-service.js";
|
||||
import { createInventoryImageRepository } from "../modules/inventory/repositories/inventory-image-repository.js";
|
||||
import { createImageDerivativeJobRepository } from "../modules/inventory/repositories/image-derivative-job-repository.js";
|
||||
import { createInventoryImageService } from "../modules/inventory/services/inventory-image-service.js";
|
||||
import { createLocalImageStorage } from "../infrastructure/images/local-image-storage.js";
|
||||
import { createConditionRepository } from "../modules/inventory/repositories/condition-repository.js";
|
||||
import { createListingRepository } from "../modules/listings/repositories/listing-repository.js";
|
||||
import { createListingService } from "../modules/listings/services/listing-service.js";
|
||||
import { createPublicListingService } from "../modules/listings/services/public-listing-service.js";
|
||||
import { createProductRepository } from "../modules/products/repositories/product-repository.js";
|
||||
import { createProductService } from "../modules/products/services/product-service.js";
|
||||
import { createPublicProductService } from "../modules/products/services/public-product-service.js";
|
||||
import { createSearchService } from "../modules/search/services/search-service.js";
|
||||
import { createSellerRepository } from "../modules/sellers/repositories/seller-repository.js";
|
||||
import { createPublicSellerService } from "../modules/sellers/services/public-seller-service.js";
|
||||
import { createSaleRepository } from "../modules/sales/repositories/sale-repository.js";
|
||||
import { createSaleService } from "../modules/sales/services/sale-service.js";
|
||||
import { createAcquisitionRepository } from "../modules/acquisitions/repositories/acquisition-repository.js";
|
||||
import { createAcquisitionService } from "../modules/acquisitions/services/acquisition-service.js";
|
||||
import { createDashboardService } from "../modules/dashboard/services/dashboard-service.js";
|
||||
import { createPricingRepository } from "../modules/pricing/repositories/pricing-repository.js";
|
||||
import { createPricingService } from "../modules/pricing/services/pricing-service.js";
|
||||
import { createPricingRefreshJobRepository } from "../modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||
import { createProviderMappingRepository } from "../modules/pricing/repositories/provider-mapping-repository.js";
|
||||
import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
|
||||
import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
|
||||
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
||||
import {
|
||||
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) {
|
||||
const port = Number.parseInt(value ?? '3000', 10);
|
||||
const port = Number.parseInt(value ?? "3000", 10);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('API_PORT must be an integer between 1 and 65535.');
|
||||
throw new Error("API_PORT must be an integer between 1 and 65535.");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
||||
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 redis = await createRedisConnection();
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const catalogLookupRepository = createCatalogLookupRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
const imageDerivativeJobRepository = createImageDerivativeJobRepository(database.db);
|
||||
const imageDerivativeJobRepository = createImageDerivativeJobRepository(
|
||||
database.db,
|
||||
);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const sellerRepository = createSellerRepository(database.db);
|
||||
const conditionRepository = createConditionRepository(database.db);
|
||||
const saleRepository = createSaleRepository(database.db);
|
||||
const acquisitionRepository = createAcquisitionRepository(database.db);
|
||||
const pricingRepository = createPricingRepository(database.db);
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
||||
const providerMappingRepository = createProviderMappingRepository(database.db);
|
||||
const pricingProviders = process.env.PRICING_FIXTURE_PROVIDER_ENABLED === 'true'
|
||||
? createPricingProviderRegistry([createFixturePricingProvider()])
|
||||
: createPricingProviderRegistry();
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||
database.db,
|
||||
);
|
||||
const providerMappingRepository = createProviderMappingRepository(
|
||||
database.db,
|
||||
);
|
||||
const 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 sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
@@ -105,11 +160,15 @@ async function startServer() {
|
||||
const app = buildApp({
|
||||
authenticationService,
|
||||
authenticateAdmin: (request) =>
|
||||
authenticationService.authenticate(request.cookies.pokemon_inventory_session),
|
||||
authenticationService.authenticate(
|
||||
request.cookies.pokemon_inventory_session,
|
||||
),
|
||||
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
||||
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }),
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
loginRateLimiter: createRedisLoginRateLimiter({
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
mfaService,
|
||||
passwordRecoveryService: createPasswordRecoveryService({
|
||||
passwordResetNotifier,
|
||||
@@ -118,49 +177,67 @@ async function startServer() {
|
||||
userRepository,
|
||||
}),
|
||||
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
||||
keyPrefix: 'pokemon-inventory:auth:password-reset:',
|
||||
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
inventoryItemService,
|
||||
adminExportService: createAdminExportService({
|
||||
inventoryExportJobRepository: createInventoryExportJobRepository(database.db),
|
||||
inventoryItemService,
|
||||
}),
|
||||
sellerSettingsService,
|
||||
savedViewService,
|
||||
catalogLookupRepository,
|
||||
publicImageService,
|
||||
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
||||
publicInventoryService: createPublicInventoryService({
|
||||
inventoryItemRepository,
|
||||
publicImageService,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({
|
||||
imageInspector: createSharpImageInspector(),
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
sellerSettingsRepository,
|
||||
}),
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
saleService: createSaleService({ saleRepository }),
|
||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||
dashboardService: createDashboardService({
|
||||
productService: createProductService({ productRepository }),
|
||||
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
inventoryItemService,
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
inventoryImageRepository,
|
||||
}),
|
||||
saleService: createSaleService({ saleRepository }),
|
||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
}),
|
||||
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository, pricingProviderRegistry: pricingProviders }),
|
||||
providerMappingService: createProviderMappingService({ providerMappingRepository }),
|
||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||
pricingService,
|
||||
providerMappingService: createProviderMappingService({
|
||||
providerMappingRepository,
|
||||
}),
|
||||
publicListingService: createPublicListingService({
|
||||
listingRepository,
|
||||
publicImageService,
|
||||
}),
|
||||
productService: createProductService({ productRepository }),
|
||||
publicProductService: createPublicProductService({ productRepository }),
|
||||
searchService: createSearchService({ listingRepository }),
|
||||
publicSellerService: createPublicSellerService({ sellerRepository }),
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
app.addHook("onClose", async () => {
|
||||
await database.close();
|
||||
await redis.close();
|
||||
});
|
||||
|
||||
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) });
|
||||
await app.listen({ host: "0.0.0.0", port: parsePort(process.env.API_PORT) });
|
||||
}
|
||||
|
||||
startServer().catch((error) => {
|
||||
|
||||
@@ -10,100 +10,143 @@ import {
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const userRole = pgEnum('user_role', ['ADMIN']);
|
||||
export const inventoryStatus = pgEnum('inventory_status', [
|
||||
'ACTIVE',
|
||||
'SOLD',
|
||||
'ARCHIVED',
|
||||
export const userRole = pgEnum("user_role", ["ADMIN"]);
|
||||
export const inventoryStatus = pgEnum("inventory_status", [
|
||||
"ACTIVE",
|
||||
"SOLD",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
export const listingStatus = pgEnum('listing_status', [
|
||||
'DRAFT',
|
||||
'AVAILABLE',
|
||||
'PENDING',
|
||||
'CLOSED',
|
||||
'ARCHIVED',
|
||||
export const listingStatus = pgEnum("listing_status", [
|
||||
"DRAFT",
|
||||
"AVAILABLE",
|
||||
"PENDING",
|
||||
"CLOSED",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
};
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
"users",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: userRole('role').default('ADMIN').notNull(),
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
role: userRole("role").default("ADMIN").notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('users_email_unique').on(table.email)],
|
||||
(table) => [uniqueIndex("users_email_unique").on(table.email)],
|
||||
);
|
||||
|
||||
export const sellers = pgTable(
|
||||
'sellers',
|
||||
"sellers",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
displayName: text('display_name').notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)],
|
||||
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
|
||||
);
|
||||
|
||||
export const 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(
|
||||
'sessions',
|
||||
"sessions",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
sessionTokenHash: text('session_token_hash').notNull(),
|
||||
csrfTokenHash: text('csrf_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true })
|
||||
sessionTokenHash: text("session_token_hash").notNull(),
|
||||
csrfTokenHash: text("csrf_token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp("last_activity_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash),
|
||||
index('sessions_user_id_index').on(table.userId),
|
||||
index('sessions_expires_at_index').on(table.expiresAt),
|
||||
uniqueIndex("sessions_token_hash_unique").on(table.sessionTokenHash),
|
||||
index("sessions_user_id_index").on(table.userId),
|
||||
index("sessions_expires_at_index").on(table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const securityAuditEvents = pgTable(
|
||||
'security_audit_events',
|
||||
"security_audit_events",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id),
|
||||
event: text('event').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
event: text("event").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
metadata: jsonb("metadata").default({}).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('security_audit_events_user_id_index').on(table.userId),
|
||||
index('security_audit_events_event_created_at_index').on(
|
||||
index("security_audit_events_user_id_index").on(table.userId),
|
||||
index("security_audit_events_event_created_at_index").on(
|
||||
table.event,
|
||||
table.createdAt,
|
||||
),
|
||||
@@ -111,89 +154,89 @@ export const securityAuditEvents = pgTable(
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
'password_reset_tokens',
|
||||
"password_reset_tokens",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash),
|
||||
index('password_reset_tokens_user_id_index').on(table.userId),
|
||||
uniqueIndex("password_reset_tokens_token_hash_unique").on(table.tokenHash),
|
||||
index("password_reset_tokens_user_id_index").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const mfaMethods = pgTable(
|
||||
'mfa_methods',
|
||||
"mfa_methods",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text('type').notNull(),
|
||||
secretCiphertext: text('secret_ciphertext').notNull(),
|
||||
name: text('name').notNull(),
|
||||
isEnabled: boolean('is_enabled').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
type: text("type").notNull(),
|
||||
secretCiphertext: text("secret_ciphertext").notNull(),
|
||||
name: text("name").notNull(),
|
||||
isEnabled: boolean("is_enabled").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [index('mfa_methods_user_id_index').on(table.userId)],
|
||||
(table) => [index("mfa_methods_user_id_index").on(table.userId)],
|
||||
);
|
||||
|
||||
export const categories = pgTable(
|
||||
'categories',
|
||||
"categories",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('categories_name_unique').on(table.name),
|
||||
uniqueIndex('categories_slug_unique').on(table.slug),
|
||||
uniqueIndex("categories_name_unique").on(table.name),
|
||||
uniqueIndex("categories_slug_unique").on(table.slug),
|
||||
],
|
||||
);
|
||||
|
||||
export const conditionProfiles = pgTable('condition_profiles', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
categoryId: uuid('category_id')
|
||||
export const conditionProfiles = pgTable("condition_profiles", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
categoryId: uuid("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
name: text("name").notNull(),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const conditions = pgTable(
|
||||
'conditions',
|
||||
"conditions",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid('condition_profile_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid("condition_profile_id")
|
||||
.notNull()
|
||||
.references(() => conditionProfiles.id),
|
||||
name: text('name').notNull(),
|
||||
code: text('code').notNull(),
|
||||
qualityRank: integer('quality_rank').notNull(),
|
||||
description: text('description'),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
name: text("name").notNull(),
|
||||
code: text("code").notNull(),
|
||||
qualityRank: integer("quality_rank").notNull(),
|
||||
description: text("description"),
|
||||
active: boolean("active").default(true).notNull(),
|
||||
sort: integer("sort").default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('conditions_profile_code_unique').on(
|
||||
uniqueIndex("conditions_profile_code_unique").on(
|
||||
table.conditionProfileId,
|
||||
table.code,
|
||||
),
|
||||
@@ -201,229 +244,291 @@ export const conditions = pgTable(
|
||||
);
|
||||
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
"products",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
categoryId: uuid('category_id')
|
||||
categoryId: uuid("category_id")
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
description: text('description'),
|
||||
brand: text('brand'),
|
||||
setName: text('set_name'),
|
||||
cardNumber: text('card_number'),
|
||||
eventName: text('event_name'),
|
||||
releaseYear: integer('release_year'),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
description: text("description"),
|
||||
brand: text("brand"),
|
||||
setName: text("set_name"),
|
||||
cardNumber: text("card_number"),
|
||||
eventName: text("event_name"),
|
||||
releaseYear: integer("release_year"),
|
||||
tags: text("tags").array().default([]).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug),
|
||||
uniqueIndex("products_seller_slug_unique").on(table.sellerId, table.slug),
|
||||
check(
|
||||
'products_release_year_valid',
|
||||
"products_release_year_valid",
|
||||
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const inventoryItems = pgTable('inventory_items', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id')
|
||||
export const inventoryItems = pgTable("inventory_items", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
conditionId: uuid('condition_id')
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
status: inventoryStatus('status').default('ACTIVE').notNull(),
|
||||
notes: text('notes'),
|
||||
status: inventoryStatus("status").default("ACTIVE").notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const acquisitions = pgTable('acquisitions', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
export const acquisitions = pgTable("acquisitions", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
method: text('method').notNull(),
|
||||
costMinor: integer('cost_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
date: text('date'),
|
||||
source: text('source'),
|
||||
location: text('location'),
|
||||
shippingMinor: integer('shipping_minor').default(0).notNull(),
|
||||
taxMinor: integer('tax_minor').default(0).notNull(),
|
||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||
estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(),
|
||||
notes: text('notes'),
|
||||
method: text("method").notNull(),
|
||||
costMinor: integer("cost_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
date: text("date"),
|
||||
source: text("source"),
|
||||
location: text("location"),
|
||||
shippingMinor: integer("shipping_minor").default(0).notNull(),
|
||||
taxMinor: integer("tax_minor").default(0).notNull(),
|
||||
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||
estimatedTradeValueMinor: integer("estimated_trade_value_minor")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pricingObservations = pgTable('pricing_observations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id').notNull().references(() => products.id),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
providerId: text('provider_id').notNull(),
|
||||
priceMinor: integer('price_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
sourceIdentifier: text('source_identifier').notNull(),
|
||||
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
|
||||
sourceUrl: text('source_url'),
|
||||
qualityScore: integer('quality_score'),
|
||||
isOutlier: boolean('is_outlier').default(false).notNull(),
|
||||
outlierReason: text('outlier_reason'),
|
||||
observedAt: timestamp('observed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
...timestamps,
|
||||
}, (table) => [index('pricing_observations_inventory_item_id_index').on(table.inventoryItemId)]);
|
||||
|
||||
export const marketValuations = pgTable('market_valuations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
valueMinor: integer('value_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
strategy: text('strategy').notNull(),
|
||||
confidence: text('confidence').notNull(),
|
||||
sourceCount: integer('source_count').notNull(),
|
||||
calculatedAt: timestamp('calculated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const marketValueOverrides = pgTable('market_value_overrides', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
valueMinor: integer('value_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const pricingProviderMappings = pgTable('pricing_provider_mappings', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id').notNull().references(() => products.id),
|
||||
providerId: text('provider_id').notNull(),
|
||||
mappingType: text('mapping_type').notNull(),
|
||||
externalIdentifier: text('external_identifier'),
|
||||
searchQuery: text('search_query'),
|
||||
sourceUrl: text('source_url'),
|
||||
status: text('status').default('ACTIVE').notNull(),
|
||||
metadata: jsonb('metadata').default({}).notNull(),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id').notNull().references(() => sellers.id),
|
||||
inventoryItemId: uuid('inventory_item_id').notNull().references(() => inventoryItems.id),
|
||||
productId: uuid('product_id').notNull().references(() => products.id),
|
||||
conditionId: uuid('condition_id').notNull().references(() => conditions.id),
|
||||
providerId: text('provider_id').notNull(),
|
||||
status: text('status').default('PENDING').notNull(),
|
||||
attemptCount: integer('attempt_count').default(0).notNull(),
|
||||
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }),
|
||||
observations: jsonb('observations').default([]).notNull(),
|
||||
valuation: jsonb('valuation'),
|
||||
error: text('error'),
|
||||
...timestamps,
|
||||
}, (table) => [index('pricing_refresh_jobs_status_created_at_index').on(table.status, table.createdAt)]);
|
||||
|
||||
export const inventoryImages = pgTable(
|
||||
'inventory_images',
|
||||
export const pricingObservations = pgTable(
|
||||
"pricing_observations",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
originalFilename: text('original_filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
fileSize: integer('file_size').notNull(),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
altText: text('alt_text'),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||
processingStatus: text('processing_status').default('PENDING').notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
externalIdentifier: text("external_identifier"),
|
||||
searchQuery: text("search_query"),
|
||||
priceMinor: integer("price_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
sourceIdentifier: text("source_identifier").notNull(),
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
sourceUrl: text("source_url"),
|
||||
qualityScore: integer("quality_score"),
|
||||
isOutlier: boolean("is_outlier").default(false).notNull(),
|
||||
outlierReason: text("outlier_reason"),
|
||||
observedAt: timestamp("observed_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)],
|
||||
(table) => [
|
||||
index("pricing_observations_inventory_item_id_index").on(
|
||||
table.inventoryItemId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const marketValuations = pgTable("market_valuations", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
valueMinor: integer("value_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
strategy: text("strategy").notNull(),
|
||||
confidence: text("confidence").notNull(),
|
||||
sourceCount: integer("source_count").notNull(),
|
||||
calculatedAt: timestamp("calculated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const marketValueOverrides = pgTable("market_value_overrides", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
valueMinor: integer("value_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
reason: text("reason").notNull(),
|
||||
createdBy: uuid("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const pricingProviderMappings = pgTable("pricing_provider_mappings", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
providerId: text("provider_id").notNull(),
|
||||
mappingType: text("mapping_type").notNull(),
|
||||
externalIdentifier: text("external_identifier"),
|
||||
searchQuery: text("search_query"),
|
||||
sourceUrl: text("source_url"),
|
||||
status: text("status").default("ACTIVE").notNull(),
|
||||
metadata: jsonb("metadata").default({}).notNull(),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pricingRefreshJobs = pgTable(
|
||||
"pricing_refresh_jobs",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productId: uuid("product_id")
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
providerId: text("provider_id").notNull(),
|
||||
status: text("status").default("PENDING").notNull(),
|
||||
attemptCount: integer("attempt_count").default(0).notNull(),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
|
||||
observations: jsonb("observations").default([]).notNull(),
|
||||
valuation: jsonb("valuation"),
|
||||
error: text("error"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index("pricing_refresh_jobs_status_created_at_index").on(
|
||||
table.status,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const inventoryImages = pgTable(
|
||||
"inventory_images",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
storageKey: text("storage_key").notNull(),
|
||||
originalFilename: text("original_filename").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
altText: text("alt_text"),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
isPrimary: boolean("is_primary").default(false).notNull(),
|
||||
processingStatus: text("processing_status").default("PENDING").notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index("inventory_images_inventory_item_id_index").on(table.inventoryItemId),
|
||||
],
|
||||
);
|
||||
|
||||
export const imageDerivativeJobs = pgTable(
|
||||
'image_derivative_jobs',
|
||||
"image_derivative_jobs",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryImageId: uuid('inventory_image_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryImageId: uuid("inventory_image_id")
|
||||
.notNull()
|
||||
.references(() => inventoryImages.id),
|
||||
sourceKey: text('source_key').notNull(),
|
||||
status: text('status').default('PENDING').notNull(),
|
||||
derivatives: jsonb('derivatives').default([]).notNull(),
|
||||
error: text('error'),
|
||||
sourceKey: text("source_key").notNull(),
|
||||
status: text("status").default("PENDING").notNull(),
|
||||
derivatives: jsonb("derivatives").default([]).notNull(),
|
||||
error: text("error"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index('image_derivative_jobs_image_id_index').on(table.inventoryImageId)],
|
||||
(table) => [
|
||||
index("image_derivative_jobs_image_id_index").on(table.inventoryImageId),
|
||||
],
|
||||
);
|
||||
|
||||
export const listings = pgTable('listings', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
export const listings = pgTable(
|
||||
"listings",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productName: text('product_name').notNull(),
|
||||
categoryId: uuid('category_id').notNull(),
|
||||
conditionId: uuid('condition_id')
|
||||
productName: text("product_name").notNull(),
|
||||
categoryId: uuid("category_id").notNull(),
|
||||
conditionId: uuid("condition_id")
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
askingPriceMinor: integer('asking_price_minor').notNull(),
|
||||
status: listingStatus('status').default('DRAFT').notNull(),
|
||||
notes: text('notes'),
|
||||
askingPriceMinor: integer("asking_price_minor").notNull(),
|
||||
status: listingStatus("status").default("DRAFT").notNull(),
|
||||
notes: text("notes"),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||
index('listings_status_index').on(table.status),
|
||||
]);
|
||||
},
|
||||
(table) => [
|
||||
index("listings_inventory_item_id_index").on(table.inventoryItemId),
|
||||
index("listings_status_index").on(table.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const sales = pgTable('sales', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
export const sales = pgTable("sales", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
status: text('status').default('COMPLETED').notNull(),
|
||||
saleDate: text('sale_date').notNull(),
|
||||
saleLocation: text('sale_location'),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
currency: text('currency').notNull(),
|
||||
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(),
|
||||
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(),
|
||||
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(),
|
||||
taxesMinor: integer('taxes_minor').default(0).notNull(),
|
||||
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||
buyerName: text('buyer_name'),
|
||||
buyerContact: text('buyer_contact'),
|
||||
notes: text('notes'),
|
||||
voidReason: text('void_reason'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
status: text("status").default("COMPLETED").notNull(),
|
||||
saleDate: text("sale_date").notNull(),
|
||||
saleLocation: text("sale_location"),
|
||||
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
platformFeesMinor: integer("platform_fees_minor").default(0).notNull(),
|
||||
paymentProcessingFeesMinor: integer("payment_processing_fees_minor")
|
||||
.default(0)
|
||||
.notNull(),
|
||||
shippingCostMinor: integer("shipping_cost_minor").default(0).notNull(),
|
||||
taxesMinor: integer("taxes_minor").default(0).notNull(),
|
||||
otherFeesMinor: integer("other_fees_minor").default(0).notNull(),
|
||||
buyerName: text("buyer_name"),
|
||||
buyerContact: text("buyer_contact"),
|
||||
notes: text("notes"),
|
||||
voidReason: text("void_reason"),
|
||||
voidedAt: timestamp("voided_at", { withTimezone: true }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const saleItems = pgTable(
|
||||
'sale_items',
|
||||
"sale_items",
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
saleId: uuid('sale_id')
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
saleId: uuid("sale_id")
|
||||
.notNull()
|
||||
.references(() => sales.id),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||
costBasisMinor: integer('cost_basis_minor').notNull(),
|
||||
soldPriceMinor: integer("sold_price_minor").notNull(),
|
||||
costBasisMinor: integer("cost_basis_minor").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId),
|
||||
index('sale_items_sale_id_index').on(table.saleId),
|
||||
uniqueIndex("sale_items_inventory_item_id_unique").on(
|
||||
table.inventoryItemId,
|
||||
),
|
||||
index("sale_items_sale_id_index").on(table.saleId),
|
||||
],
|
||||
);
|
||||
@@ -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) {
|
||||
return {
|
||||
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, acquisition.inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const [created] = await transaction.insert(acquisitions).values(acquisition).returning();
|
||||
return { status: 'CREATED', acquisition: created };
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const [created] = await transaction
|
||||
.insert(acquisitions)
|
||||
.values(acquisition)
|
||||
.returning();
|
||||
return { status: "CREATED", acquisition: created };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -22,7 +34,10 @@ export function createAcquisitionRepository(db) {
|
||||
return db
|
||||
.select({ acquisition: acquisitions })
|
||||
.from(acquisitions)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, acquisitions.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(acquisitions.createdAt))
|
||||
|
||||
@@ -1,42 +1,64 @@
|
||||
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition';
|
||||
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition';
|
||||
import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
|
||||
import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminAcquisitionRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
acquisitionService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
if (!admin?.sellerId)
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/acquisitions', async (request) => ({
|
||||
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto),
|
||||
app.get("/acquisitions", async (request) => ({
|
||||
data: (
|
||||
await acquisitionService.list({ sellerId: request.admin.sellerId })
|
||||
).map(toAdminAcquisitionDto),
|
||||
}));
|
||||
|
||||
app.post('/acquisitions', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/acquisitions", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createAcquisitionSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
|
||||
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId });
|
||||
const acquisition = await acquisitionService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import {
|
||||
createAcquisition,
|
||||
calculateAcquisitionCostBasis,
|
||||
} from "../domain/acquisition.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createAcquisitionService({ acquisitionRepository }) {
|
||||
return {
|
||||
async create({ sellerId, ...input }) {
|
||||
const acquisition = createAcquisition(input);
|
||||
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId });
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await acquisitionRepository.createForSeller({
|
||||
acquisition,
|
||||
sellerId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard';
|
||||
import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export async function registerAdminDashboardRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
dashboardService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
if (!admin?.sellerId)
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/dashboard', async (request) => ({
|
||||
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
|
||||
app.get("/dashboard", async (request) => ({
|
||||
data: toAdminDashboardDto(
|
||||
await dashboardService.get({ sellerId: request.admin.sellerId }),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) {
|
||||
export function createDashboardService({
|
||||
productService,
|
||||
inventoryItemService,
|
||||
listingService,
|
||||
saleService,
|
||||
acquisitionService,
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
}) {
|
||||
return {
|
||||
async get({ sellerId }) {
|
||||
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([
|
||||
const [
|
||||
products,
|
||||
inventory,
|
||||
listings,
|
||||
sales,
|
||||
acquisitions,
|
||||
valuations,
|
||||
failedPricingJobs,
|
||||
] = await Promise.all([
|
||||
productService.listBySellerId(sellerId),
|
||||
inventoryItemService.listBySellerId(sellerId),
|
||||
listingService.list({ sellerId }),
|
||||
@@ -11,32 +27,47 @@ export function createDashboardService({ productService, inventoryItemService, l
|
||||
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
|
||||
]);
|
||||
|
||||
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
|
||||
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
|
||||
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation]));
|
||||
const activeListings = listings.filter(({ status }) =>
|
||||
["AVAILABLE", "PENDING"].includes(status),
|
||||
);
|
||||
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
|
||||
const valuationByItem = new Map(
|
||||
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
|
||||
);
|
||||
const staleThreshold = 7 * 24 * 60 * 60 * 1000;
|
||||
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
|
||||
const total = (values, field) =>
|
||||
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
|
||||
|
||||
return {
|
||||
counts: {
|
||||
products: products.length,
|
||||
inventory: inventory.length,
|
||||
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
|
||||
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
|
||||
activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
|
||||
.length,
|
||||
soldInventory: inventory.filter(({ status }) => status === "SOLD")
|
||||
.length,
|
||||
availableListings: activeListings.length,
|
||||
sales: completedSales.length,
|
||||
acquisitions: acquisitions.length,
|
||||
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
|
||||
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length,
|
||||
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length,
|
||||
missingImages: listings.filter(({ imageCount }) => imageCount < 1)
|
||||
.length,
|
||||
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
|
||||
.length,
|
||||
stalePricing: valuations.filter(
|
||||
({ calculatedAt }) =>
|
||||
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
|
||||
).length,
|
||||
failedPricingJobs,
|
||||
},
|
||||
financials: {
|
||||
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
|
||||
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
|
||||
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
|
||||
totalProfitMinor: total(completedSales, 'profitMinor'),
|
||||
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'),
|
||||
totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
|
||||
totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
|
||||
totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
|
||||
totalProfitMinor: total(completedSales, "profitMinor"),
|
||||
totalMarketValueMinor: total(
|
||||
[...valuationByItem.values()],
|
||||
"valueMinor",
|
||||
),
|
||||
},
|
||||
recentSales: completedSales.slice(0, 5),
|
||||
recentAcquisitions: acquisitions.slice(0, 5),
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -43,3 +43,16 @@ export function validateImageSignature(data, mimeType) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function archiveInventoryItem(status) {
|
||||
return transitionInventoryStatus(status, INVENTORY_STATUS.ARCHIVED);
|
||||
}
|
||||
|
||||
export function assertInventoryItemEditable(status) {
|
||||
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
|
||||
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 { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
import {
|
||||
imageDerivativeJobs,
|
||||
@@ -23,6 +25,24 @@ export function createInventoryImageRepository(db) {
|
||||
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) {
|
||||
const rows = await db
|
||||
.select({ id: inventoryImages.id })
|
||||
@@ -53,10 +73,42 @@ export function createInventoryImageRepository(db) {
|
||||
eq(inventoryImages.inventoryItemId, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
))
|
||||
.orderBy(inventoryImages.sortOrder)
|
||||
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
|
||||
.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) {
|
||||
const owned = await this.findByIdForSeller(imageId, sellerId);
|
||||
if (!owned) return null;
|
||||
@@ -135,7 +187,7 @@ export function createInventoryImageRepository(db) {
|
||||
eq(inventoryImages.processingStatus, 'COMPLETED'),
|
||||
eq(imageDerivativeJobs.status, 'COMPLETED'),
|
||||
))
|
||||
.orderBy(inventoryImages.sortOrder);
|
||||
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
|
||||
},
|
||||
|
||||
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 {
|
||||
inventoryItems,
|
||||
@@ -35,6 +35,46 @@ export function createInventoryItemRepository(db) {
|
||||
.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 } = {}) {
|
||||
const conditions = [
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
const data = await part.toBuffer();
|
||||
const fields = part.fields;
|
||||
const originalFilename = part.filename;
|
||||
const result = createInventoryImageSchema.safeParse({
|
||||
const result = uploadInventoryImageSchema.safeParse({
|
||||
storageKey: `inventory/${request.params.id}/${randomUUID()}`,
|
||||
originalFilename,
|
||||
mimeType: part.mimetype,
|
||||
fileSize: data.length,
|
||||
width: Number(fields.width?.value),
|
||||
height: Number(fields.height?.value),
|
||||
altText: fields.altText?.value,
|
||||
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
|
||||
isPrimary: fields.isPrimary?.value === 'true',
|
||||
@@ -98,11 +96,25 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
return admin;
|
||||
}
|
||||
|
||||
app.patch('/images/:id/order', async (request) => {
|
||||
app.post('/images/:id/move', async (request, reply) => {
|
||||
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({
|
||||
imageId: request.params.id,
|
||||
sortOrder: request.body?.sortOrder,
|
||||
sortOrder: result.data.sortOrder,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
@@ -117,11 +129,13 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
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 result = updateImageAltTextSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const image = await inventoryImageService.updateAltText({
|
||||
imageId: request.params.id,
|
||||
altText: request.body?.altText ?? null,
|
||||
altText: result.data.altText,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -58,4 +61,44 @@ export async function registerAdminInventoryRoutes(app, options) {
|
||||
app.get('/inventory', async (request) => ({
|
||||
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) {
|
||||
const { catalogLookupRepository } = options;
|
||||
|
||||
app.get('/public/lookups/categories', async () => ({
|
||||
app.get("/public/lookups/categories", async () => ({
|
||||
data: await catalogLookupRepository.listCategories(),
|
||||
}));
|
||||
|
||||
app.get('/public/lookups/conditions', async () => ({
|
||||
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })),
|
||||
app.get("/public/lookups/conditions", async () => ({
|
||||
data: (await catalogLookupRepository.listConditions()).map(
|
||||
({ id, name, code }) => ({ id, name, code }),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { validateImageMove } from '../domain/image-order.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 {
|
||||
async create(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.sellerId,
|
||||
maxImages,
|
||||
);
|
||||
|
||||
if (!image) {
|
||||
@@ -29,8 +37,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
|
||||
async upload(input) {
|
||||
const { data, ...metadata } = input;
|
||||
validateInventoryImageMetadata(metadata);
|
||||
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 });
|
||||
|
||||
@@ -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 }) {
|
||||
validateImageSortOrder(sortOrder);
|
||||
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
|
||||
},
|
||||
|
||||
@@ -51,7 +76,7 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
},
|
||||
|
||||
async updateAltText({ imageId, altText, sellerId }) {
|
||||
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, altText, sellerId));
|
||||
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, normalizeImageAltText(altText), sellerId));
|
||||
},
|
||||
|
||||
async delete({ imageId, sellerId }) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
export function createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
pricingService,
|
||||
}) {
|
||||
return {
|
||||
async listBySellerId(sellerId) {
|
||||
@@ -35,5 +36,45 @@ export function createInventoryItemService({
|
||||
|
||||
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 {
|
||||
conditions,
|
||||
@@ -7,12 +17,17 @@ import {
|
||||
marketValueOverrides,
|
||||
marketValuations,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
sellers,
|
||||
sellerSettings,
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createListingRepository(db) {
|
||||
return {
|
||||
async create(listing) {
|
||||
const [createdListing] = await db.insert(listings).values(listing).returning();
|
||||
const [createdListing] = await db
|
||||
.insert(listings)
|
||||
.values(listing)
|
||||
.returning();
|
||||
return createdListing;
|
||||
},
|
||||
|
||||
@@ -28,18 +43,28 @@ export function createListingRepository(db) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
|
||||
.where(
|
||||
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
|
||||
);
|
||||
|
||||
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
|
||||
return result
|
||||
? { ...result.listing, inventoryStatus: result.inventoryStatus }
|
||||
: null;
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
const results = await db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(listings.createdAt));
|
||||
@@ -49,43 +74,75 @@ export function createListingRepository(db) {
|
||||
|
||||
async searchPublic(filters = {}) {
|
||||
const {
|
||||
text = '',
|
||||
text = "",
|
||||
category,
|
||||
conditionId,
|
||||
setName,
|
||||
eventName,
|
||||
tag,
|
||||
status,
|
||||
newlyAdded = false,
|
||||
minPriceMinor,
|
||||
maxPriceMinor,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
includeSold = false,
|
||||
sort = 'recent',
|
||||
sort = "recent",
|
||||
} = filters;
|
||||
|
||||
const conditions = [
|
||||
inArray(
|
||||
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));
|
||||
}
|
||||
|
||||
if (typeof conditionId === 'string' && conditionId.trim().length > 0) {
|
||||
if (typeof conditionId === "string" && conditionId.trim().length > 0) {
|
||||
conditions.push(eq(inventoryItems.conditionId, conditionId));
|
||||
}
|
||||
|
||||
if (typeof minPriceMinor === 'number') {
|
||||
if (typeof 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}`);
|
||||
}
|
||||
|
||||
if (typeof maxPriceMinor === 'number') {
|
||||
if (typeof maxPriceMinor === "number") {
|
||||
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
|
||||
}
|
||||
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
const searchTerms = text.split(' ').filter(Boolean);
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
const searchTerms = text.split(" ").filter(Boolean);
|
||||
|
||||
if (searchTerms.length > 0) {
|
||||
conditions.push(
|
||||
@@ -97,6 +154,7 @@ export function createListingRepository(db) {
|
||||
ilike(products.description, `%${term}%`),
|
||||
ilike(products.setName, `%${term}%`),
|
||||
ilike(products.brand, `%${term}%`),
|
||||
sql`${products.tags}::text ILIKE ${`%${term}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,9 +163,30 @@ export function createListingRepository(db) {
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const sortOrder = sort === 'price_low'
|
||||
const marketValue = sql`COALESCE(
|
||||
(SELECT ${marketValueOverrides.valueMinor}
|
||||
FROM ${marketValueOverrides}
|
||||
WHERE ${marketValueOverrides.inventoryItemId} = ${inventoryItems.id}
|
||||
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'
|
||||
: sort === "price_high"
|
||||
? desc(listings.askingPriceMinor)
|
||||
: desc(listings.createdAt);
|
||||
|
||||
@@ -115,13 +194,19 @@ export function createListingRepository(db) {
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(...conditions)),
|
||||
db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(sortOrder, desc(listings.createdAt))
|
||||
@@ -137,30 +222,60 @@ export function createListingRepository(db) {
|
||||
|
||||
async findPublicById(listingId) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, condition: conditions })
|
||||
.select({
|
||||
listing: listings,
|
||||
condition: conditions,
|
||||
sellerDisplayName: sellers.displayName,
|
||||
sellerSettings: sellerSettings.settings,
|
||||
})
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, listings.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
|
||||
.where(and(
|
||||
.innerJoin(sellers, eq(sellers.id, products.sellerId))
|
||||
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
|
||||
.where(
|
||||
and(
|
||||
eq(listings.id, listingId),
|
||||
inArray(listings.status, ['AVAILABLE', 'PENDING']),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
));
|
||||
inArray(listings.status, ["AVAILABLE", "PENDING"]),
|
||||
eq(inventoryItems.status, "ACTIVE"),
|
||||
),
|
||||
);
|
||||
|
||||
if (!result) return null;
|
||||
const [override] = await db.select().from(marketValueOverrides)
|
||||
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId))
|
||||
.orderBy(desc(marketValueOverrides.createdAt)).limit(1);
|
||||
const [valuation] = await db.select().from(marketValuations)
|
||||
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId))
|
||||
.orderBy(desc(marketValuations.calculatedAt)).limit(1);
|
||||
const [override] = await db
|
||||
.select()
|
||||
.from(marketValueOverrides)
|
||||
.where(
|
||||
eq(
|
||||
marketValueOverrides.inventoryItemId,
|
||||
result.listing.inventoryItemId,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(marketValueOverrides.createdAt))
|
||||
.limit(1);
|
||||
const [valuation] = await db
|
||||
.select()
|
||||
.from(marketValuations)
|
||||
.where(
|
||||
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
|
||||
)
|
||||
.orderBy(desc(marketValuations.calculatedAt))
|
||||
.limit(1);
|
||||
return {
|
||||
...result.listing,
|
||||
conditionName: result.condition.name,
|
||||
conditionCode: result.condition.code,
|
||||
marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? 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) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
@@ -19,28 +19,30 @@ export function createPricingObservation({
|
||||
inventoryItemId,
|
||||
providerId,
|
||||
priceMinor,
|
||||
currency = 'USD',
|
||||
currency = "USD",
|
||||
sourceIdentifier,
|
||||
conditionId,
|
||||
sourceUrl,
|
||||
qualityScore,
|
||||
}) {
|
||||
return {
|
||||
productId: requiredText(productId, 'Product ID'),
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
providerId: requiredText(providerId, 'Provider ID'),
|
||||
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
|
||||
currency: requiredText(currency, 'Currency'),
|
||||
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
|
||||
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
|
||||
productId: requiredText(productId, "Product ID"),
|
||||
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||
providerId: requiredText(providerId, "Provider ID"),
|
||||
priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
|
||||
currency: requiredText(currency, "Currency"),
|
||||
sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
|
||||
conditionId: requiredText(conditionId, "Condition ID"),
|
||||
...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
|
||||
? { sourceUrl: sourceUrl.trim() }
|
||||
: {}),
|
||||
...(typeof qualityScore === "number" ? { qualityScore } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateMedianPriceMinor(prices) {
|
||||
if (!Array.isArray(prices) || prices.length === 0) {
|
||||
throw new Error('At least one price is required.');
|
||||
throw new Error("At least one price is required.");
|
||||
}
|
||||
|
||||
const sorted = [...prices].sort((left, right) => left - right);
|
||||
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
|
||||
if (!Array.isArray(prices) || prices.length < 4) return new Set();
|
||||
const sorted = [...prices].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2
|
||||
const median =
|
||||
sorted.length % 2
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
const lowerFence = median / 3;
|
||||
const upperFence = median * 3;
|
||||
return new Set(prices.filter((price) => price < lowerFence || price > upperFence));
|
||||
return new Set(
|
||||
prices.filter((price) => price < lowerFence || price > upperFence),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { createPricingProvider } from './pricing-provider.js';
|
||||
import { createPricingProvider } from "./pricing-provider.js";
|
||||
|
||||
export function createFixturePricingProvider() {
|
||||
return createPricingProvider({
|
||||
id: 'fixture-provider',
|
||||
id: "fixture-provider",
|
||||
async observe({ productId, inventoryItemId, conditionId }) {
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
productId,
|
||||
inventoryItemId,
|
||||
conditionId,
|
||||
priceMinor: 2500,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
sourceIdentifier: `fixture-${inventoryItemId}`,
|
||||
qualityScore: 50,
|
||||
}];
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
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({
|
||||
id,
|
||||
endpoint,
|
||||
@@ -10,10 +28,21 @@ export function createHttpPricingProvider({
|
||||
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({
|
||||
id,
|
||||
async observe(input) {
|
||||
const url = new URL(endpoint);
|
||||
const url = new URL(parsedUrl.toString());
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export function createPricingProvider({ id, observe }) {
|
||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||
throw new Error('Pricing provider id is required.');
|
||||
if (typeof id !== "string" || id.trim().length === 0) {
|
||||
throw new Error("Pricing provider id is required.");
|
||||
}
|
||||
|
||||
if (typeof observe !== 'function') {
|
||||
throw new TypeError('Pricing provider observe function is required.');
|
||||
if (typeof observe !== "function") {
|
||||
throw new TypeError("Pricing provider observe function is required.");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
|
||||
async observe(input) {
|
||||
const observations = await observe(input);
|
||||
if (!Array.isArray(observations)) {
|
||||
throw new Error(`Pricing provider ${id} must return an observation array.`);
|
||||
throw new Error(
|
||||
`Pricing provider ${id} must return an observation array.`,
|
||||
);
|
||||
}
|
||||
return observations;
|
||||
},
|
||||
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
|
||||
}
|
||||
|
||||
export function createPricingProviderRegistry(providers = []) {
|
||||
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
|
||||
const providerMap = new Map(
|
||||
providers.map((provider) => [provider.id, provider]),
|
||||
);
|
||||
|
||||
return {
|
||||
get(providerId) {
|
||||
|
||||
@@ -1,93 +1,178 @@
|
||||
import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, isNull, lte, or } from "drizzle-orm";
|
||||
|
||||
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
|
||||
import { pricingRefreshJobs } from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createPricingRefreshJobRepository(db) {
|
||||
const maxAttempts = 3;
|
||||
return {
|
||||
async create(job) {
|
||||
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
|
||||
const [created] = await db
|
||||
.insert(pricingRefreshJobs)
|
||||
.values(job)
|
||||
.returning();
|
||||
return created;
|
||||
},
|
||||
|
||||
async claimNext() {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [job] = await transaction.select().from(pricingRefreshJobs)
|
||||
.where(and(
|
||||
eq(pricingRefreshJobs.status, 'PENDING'),
|
||||
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
|
||||
))
|
||||
const [job] = await transaction
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.status, "PENDING"),
|
||||
or(
|
||||
isNull(pricingRefreshJobs.nextAttemptAt),
|
||||
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(pricingRefreshJobs.createdAt))
|
||||
.limit(1)
|
||||
.for('update', { skipLocked: true });
|
||||
.for("update", { skipLocked: true });
|
||||
if (!job) return null;
|
||||
const [claimed] = await transaction.update(pricingRefreshJobs)
|
||||
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
|
||||
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
|
||||
const [claimed] = await transaction
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: "PROCESSING",
|
||||
attemptCount: pricingRefreshJobs.attemptCount,
|
||||
updatedAt: new Date(),
|
||||
error: null,
|
||||
nextAttemptAt: null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, job.id),
|
||||
eq(pricingRefreshJobs.status, "PENDING"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return claimed ?? null;
|
||||
});
|
||||
},
|
||||
|
||||
async findByIdForSeller(id, sellerId) {
|
||||
const [job] = await db.select().from(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId)));
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
return job ?? null;
|
||||
},
|
||||
|
||||
async countFailedForSeller(sellerId) {
|
||||
const rows = await db.select({ id: pricingRefreshJobs.id })
|
||||
const rows = await db
|
||||
.select({ id: pricingRefreshJobs.id })
|
||||
.from(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')));
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
eq(pricingRefreshJobs.status, "FAILED"),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async listForSeller(sellerId) {
|
||||
return db.select().from(pricingRefreshJobs)
|
||||
return db
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(eq(pricingRefreshJobs.sellerId, sellerId))
|
||||
.orderBy(desc(pricingRefreshJobs.createdAt));
|
||||
},
|
||||
|
||||
async retryForSeller(id, sellerId) {
|
||||
const [job] = await db.update(pricingRefreshJobs)
|
||||
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
|
||||
const [job] = await db
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: "PENDING",
|
||||
error: null,
|
||||
observations: [],
|
||||
valuation: null,
|
||||
nextAttemptAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
eq(pricingRefreshJobs.status, "FAILED"),
|
||||
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return job ?? null;
|
||||
},
|
||||
|
||||
async complete(id, observations, valuation) {
|
||||
const [completed] = await db.update(pricingRefreshJobs)
|
||||
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
|
||||
const [completed] = await db
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: "COMPLETED",
|
||||
observations,
|
||||
valuation,
|
||||
error: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return completed ?? null;
|
||||
},
|
||||
|
||||
async fail(id, message) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [current] = await transaction.select().from(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
|
||||
const [current] = await transaction
|
||||
.select()
|
||||
.from(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||
),
|
||||
);
|
||||
if (!current) return null;
|
||||
const attemptCount = current.attemptCount + 1;
|
||||
const retryable = attemptCount < maxAttempts;
|
||||
const [updated] = await transaction.update(pricingRefreshJobs)
|
||||
const [updated] = await transaction
|
||||
.update(pricingRefreshJobs)
|
||||
.set({
|
||||
status: retryable ? 'PENDING' : 'FAILED',
|
||||
status: retryable ? "PENDING" : "FAILED",
|
||||
error: message,
|
||||
attemptCount,
|
||||
updatedAt: new Date(),
|
||||
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null,
|
||||
nextAttemptAt: retryable
|
||||
? new Date(Date.now() + 2 ** attemptCount * 1000)
|
||||
: null,
|
||||
})
|
||||
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.id, id),
|
||||
eq(pricingRefreshJobs.status, "PROCESSING"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
});
|
||||
},
|
||||
|
||||
async purgeCompletedBefore(sellerId, cutoff) {
|
||||
const deleted = await db.delete(pricingRefreshJobs)
|
||||
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
|
||||
async purgeCompletedBefore(cutoff) {
|
||||
const deleted = await db
|
||||
.delete(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.status, "COMPLETED"),
|
||||
lte(pricingRefreshJobs.updatedAt, cutoff),
|
||||
),
|
||||
)
|
||||
.returning({ id: pricingRefreshJobs.id });
|
||||
return deleted.length;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js';
|
||||
import { identifyOutlierPrices } from '../domain/pricing-observation.js';
|
||||
import {
|
||||
inventoryItems,
|
||||
marketValueOverrides,
|
||||
marketValuations,
|
||||
pricingObservations,
|
||||
products,
|
||||
securityAuditEvents,
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
|
||||
|
||||
export function createPricingRepository(db) {
|
||||
return {
|
||||
@@ -10,76 +17,164 @@ export function createPricingRepository(db) {
|
||||
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' };
|
||||
const [created] = await db.insert(pricingObservations).values(observation).returning();
|
||||
return { status: 'CREATED', observation: created };
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, observation.inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem || ownedItem.productId !== observation.productId)
|
||||
return { status: "NOT_FOUND" };
|
||||
const [created] = await db
|
||||
.insert(pricingObservations)
|
||||
.values(observation)
|
||||
.returning();
|
||||
return { status: "CREATED", observation: created };
|
||||
},
|
||||
|
||||
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) {
|
||||
async calculateForSeller({
|
||||
inventoryItemId,
|
||||
sellerId,
|
||||
strategy = "MEDIAN",
|
||||
}) {
|
||||
const [ownedItem] = await db
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const observations = await db.select().from(pricingObservations)
|
||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false)))
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const observations = await db
|
||||
.select()
|
||||
.from(pricingObservations)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingObservations.inventoryItemId, inventoryItemId),
|
||||
eq(pricingObservations.isOutlier, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(pricingObservations.observedAt));
|
||||
if (!observations.length) return { status: 'NO_DATA' };
|
||||
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right);
|
||||
if (!observations.length) return { status: "NO_DATA" };
|
||||
const prices = observations
|
||||
.map(({ priceMinor }) => priceMinor)
|
||||
.sort((left, right) => left - right);
|
||||
const middle = Math.floor(prices.length / 2);
|
||||
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2);
|
||||
const [valuation] = await db.insert(marketValuations).values({
|
||||
const valueMinor =
|
||||
prices.length % 2
|
||||
? prices[middle]
|
||||
: Math.round((prices[middle - 1] + prices[middle]) / 2);
|
||||
const [valuation] = await db
|
||||
.insert(marketValuations)
|
||||
.values({
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
strategy,
|
||||
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM',
|
||||
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
|
||||
sourceCount: observations.length,
|
||||
}).returning();
|
||||
return { status: 'CALCULATED', valuation, observations };
|
||||
})
|
||||
.returning();
|
||||
return { status: "CALCULATED", valuation, observations };
|
||||
},
|
||||
|
||||
async listLatestValuationsForSeller(sellerId) {
|
||||
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id })
|
||||
const rows = await db
|
||||
.select({
|
||||
valuation: marketValuations,
|
||||
inventoryItemId: inventoryItems.id,
|
||||
})
|
||||
.from(marketValuations)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, marketValuations.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(marketValuations.calculatedAt));
|
||||
const latest = new Map();
|
||||
for (const row of rows) {
|
||||
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation);
|
||||
if (!latest.has(row.inventoryItemId))
|
||||
latest.set(row.inventoryItemId, row.valuation);
|
||||
}
|
||||
return [...latest.values()];
|
||||
},
|
||||
|
||||
async markOutliersForSeller({ inventoryItemId, sellerId }) {
|
||||
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems)
|
||||
const [ownedItem] = await db
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const observations = await db
|
||||
.select()
|
||||
.from(pricingObservations)
|
||||
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
const prices = observations.map(({ priceMinor }) => priceMinor);
|
||||
const outlierPrices = identifyOutlierPrices(prices);
|
||||
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
await db
|
||||
.update(pricingObservations)
|
||||
.set({ isOutlier: false, outlierReason: null })
|
||||
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
|
||||
if (outlierPrices.size) {
|
||||
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
|
||||
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
|
||||
await db
|
||||
.update(pricingObservations)
|
||||
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
|
||||
.where(
|
||||
and(
|
||||
eq(pricingObservations.inventoryItemId, inventoryItemId),
|
||||
inArray(pricingObservations.priceMinor, [...outlierPrices]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return { status: 'UPDATED' };
|
||||
return { status: "UPDATED" };
|
||||
},
|
||||
|
||||
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) {
|
||||
async createOverride({
|
||||
inventoryItemId,
|
||||
sellerId,
|
||||
actorUserId,
|
||||
valueMinor,
|
||||
currency,
|
||||
reason,
|
||||
}) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems)
|
||||
const [ownedItem] = await transaction
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedItem) return { status: 'NOT_FOUND' };
|
||||
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning();
|
||||
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } });
|
||||
return { status: 'CREATED', override };
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.id, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedItem) return { status: "NOT_FOUND" };
|
||||
const [override] = await transaction
|
||||
.insert(marketValueOverrides)
|
||||
.values({
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency,
|
||||
reason,
|
||||
createdBy: actorUserId,
|
||||
})
|
||||
.returning();
|
||||
await transaction.insert(securityAuditEvents).values({
|
||||
userId: actorUserId,
|
||||
event: "MARKET_VALUE_OVERRIDDEN",
|
||||
metadata: { inventoryItemId, overrideId: override.id, reason },
|
||||
});
|
||||
return { status: "CREATED", override };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js';
|
||||
import {
|
||||
pricingProviderMappings,
|
||||
products,
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createProviderMappingRepository(db) {
|
||||
return {
|
||||
async createForSeller(mapping, sellerId) {
|
||||
const [ownedProduct] = await db.select({ id: products.id }).from(products)
|
||||
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId)));
|
||||
if (!ownedProduct) return { status: 'NOT_FOUND' };
|
||||
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning();
|
||||
return { status: 'CREATED', mapping: created };
|
||||
const [ownedProduct] = await db
|
||||
.select({ id: products.id })
|
||||
.from(products)
|
||||
.where(
|
||||
and(
|
||||
eq(products.id, mapping.productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
if (!ownedProduct) return { status: "NOT_FOUND" };
|
||||
const [created] = await db
|
||||
.insert(pricingProviderMappings)
|
||||
.values(mapping)
|
||||
.returning();
|
||||
return { status: "CREATED", mapping: created };
|
||||
},
|
||||
|
||||
async listForSeller(productId, sellerId) {
|
||||
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
||||
return db
|
||||
.select({ mapping: pricingProviderMappings })
|
||||
.from(pricingProviderMappings)
|
||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||
.where(and(
|
||||
.where(
|
||||
and(
|
||||
eq(pricingProviderMappings.productId, productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||
))
|
||||
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map(({ mapping }) => mapping));
|
||||
},
|
||||
|
||||
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
|
||||
const conditions = [
|
||||
eq(pricingProviderMappings.productId, productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||
];
|
||||
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
||||
if (providerId)
|
||||
conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||
const [row] = await db
|
||||
.select({ mapping: pricingProviderMappings })
|
||||
.from(pricingProviderMappings)
|
||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||
.where(and(...conditions));
|
||||
return row?.mapping ?? null;
|
||||
},
|
||||
|
||||
async disableForSeller(id, sellerId) {
|
||||
const [mapping] = await db.select().from(pricingProviderMappings)
|
||||
const [mapping] = await db
|
||||
.select()
|
||||
.from(pricingProviderMappings)
|
||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||
.where(and(
|
||||
.where(
|
||||
and(
|
||||
eq(pricingProviderMappings.id, id),
|
||||
eq(products.sellerId, sellerId),
|
||||
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||
));
|
||||
if (!mapping) return { status: 'NOT_FOUND' };
|
||||
const [updated] = await db.update(pricingProviderMappings)
|
||||
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() })
|
||||
eq(pricingProviderMappings.status, "ACTIVE"),
|
||||
),
|
||||
);
|
||||
if (!mapping) return { status: "NOT_FOUND" };
|
||||
const [updated] = await db
|
||||
.update(pricingProviderMappings)
|
||||
.set({
|
||||
status: "DISABLED",
|
||||
disabledAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pricingProviderMappings.id, id))
|
||||
.returning();
|
||||
return { status: 'DISABLED', mapping: updated };
|
||||
return { status: "DISABLED", mapping: updated };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
|
||||
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
|
||||
import {
|
||||
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) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminPricingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
pricingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
if (!admin?.sellerId)
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.post('/pricing/observations', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/pricing/observations", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createPricingObservationSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId });
|
||||
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) });
|
||||
const observation = await pricingService.addObservation({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ data: toAdminPricingObservationDto(observation) });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/valuation', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
|
||||
app.post("/inventory/:id/valuation", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const valuation = await pricingService.calculate({
|
||||
sellerId: request.admin.sellerId,
|
||||
inventoryItemId: request.params.id,
|
||||
});
|
||||
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/valuation/override', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/inventory/:id/valuation/override", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createMarketValueOverrideSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const override = await pricingService.override({
|
||||
@@ -54,8 +92,19 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
return reply.code(201).send({ data: override });
|
||||
});
|
||||
|
||||
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.get("/valuations", async (request) => ({
|
||||
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 job = await pricingService.enqueueRefresh({
|
||||
sellerId: request.admin.sellerId,
|
||||
@@ -64,11 +113,16 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
productId,
|
||||
conditionId,
|
||||
});
|
||||
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
|
||||
return reply.code(202).send({
|
||||
data: { id: job.id, status: job.status, createdAt: job.createdAt },
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/jobs/:id', async (request) => {
|
||||
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
|
||||
app.get("/jobs/:id", async (request) => {
|
||||
const job = await pricingService.getJob({
|
||||
sellerId: request.admin.sellerId,
|
||||
jobId: request.params.id,
|
||||
});
|
||||
return {
|
||||
data: {
|
||||
id: job.id,
|
||||
@@ -81,16 +135,31 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/jobs', async (request) => ({
|
||||
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({
|
||||
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId,
|
||||
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
|
||||
app.get("/jobs", async (request) => ({
|
||||
data: (
|
||||
await pricingService.listJobs({ sellerId: request.admin.sellerId })
|
||||
).map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
providerId: job.providerId,
|
||||
inventoryItemId: job.inventoryItemId,
|
||||
error: job.error ?? null,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
})),
|
||||
}));
|
||||
|
||||
app.post('/jobs/:id/retry', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
|
||||
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
|
||||
app.post("/jobs/:id/retry", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const job = await pricingService.retryJob({
|
||||
sellerId: request.admin.sellerId,
|
||||
jobId: request.params.id,
|
||||
});
|
||||
return reply.code(202).send({
|
||||
data: { id: job.id, status: job.status, createdAt: job.createdAt },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,47 +1,79 @@
|
||||
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping';
|
||||
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping';
|
||||
import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
|
||||
import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminProviderMappingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||
authenticateAdmin = async () => {
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
},
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
providerMappingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
if (!admin?.sellerId)
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/products/:id/provider-mappings', async (request) => ({
|
||||
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto),
|
||||
app.get("/products/:id/provider-mappings", async (request) => ({
|
||||
data: (
|
||||
await providerMappingService.list({
|
||||
productId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
})
|
||||
).map(toAdminProviderMappingDto),
|
||||
}));
|
||||
|
||||
app.post('/products/:id/provider-mappings', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
app.post("/products/:id/provider-mappings", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = createProviderMappingSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId });
|
||||
const mapping = await providerMappingService.create({
|
||||
...result.data,
|
||||
productId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
|
||||
});
|
||||
|
||||
app.post('/provider-mappings/:id/disable', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId });
|
||||
app.post("/provider-mappings/:id/disable", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const mapping = await providerMappingService.disable({
|
||||
id: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,66 +1,184 @@
|
||||
import { createPricingObservation } from '../domain/pricing-observation.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createPricingObservation } from "../domain/pricing-observation.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
|
||||
export function createPricingService({
|
||||
pricingRepository,
|
||||
pricingProviderRegistry,
|
||||
pricingRefreshJobRepository,
|
||||
providerMappingRepository,
|
||||
}) {
|
||||
return {
|
||||
async addObservation({ sellerId, ...input }) {
|
||||
const observation = createPricingObservation(input);
|
||||
const result = await pricingRepository.addObservationForSeller({ observation, sellerId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await pricingRepository.addObservationForSeller({
|
||||
observation,
|
||||
sellerId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
return result.observation;
|
||||
},
|
||||
|
||||
async markOutliers({ sellerId, inventoryItemId }) {
|
||||
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
const result = await pricingRepository.markOutliersForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) {
|
||||
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400);
|
||||
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400);
|
||||
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
async override({
|
||||
sellerId,
|
||||
actorUserId,
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency = "USD",
|
||||
reason,
|
||||
}) {
|
||||
if (!Number.isInteger(valueMinor) || valueMinor < 0)
|
||||
throw createApplicationError(
|
||||
"INVALID_OVERRIDE_VALUE",
|
||||
"Override value must be a non-negative integer minor unit.",
|
||||
400,
|
||||
);
|
||||
if (typeof reason !== "string" || reason.trim().length < 3)
|
||||
throw createApplicationError(
|
||||
"OVERRIDE_REASON_REQUIRED",
|
||||
"An override reason is required.",
|
||||
400,
|
||||
);
|
||||
const result = await pricingRepository.createOverride({
|
||||
sellerId,
|
||||
actorUserId,
|
||||
inventoryItemId,
|
||||
valueMinor,
|
||||
currency: currency.toUpperCase(),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
return result.override;
|
||||
},
|
||||
|
||||
async calculate({ sellerId, inventoryItemId }) {
|
||||
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
|
||||
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
|
||||
await pricingRepository.markOutliersForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
const result = await pricingRepository.calculateForSeller({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
});
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
if (result.status === "NO_DATA")
|
||||
throw createApplicationError(
|
||||
"PRICING_DATA_UNAVAILABLE",
|
||||
"No pricing observations are available.",
|
||||
404,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
||||
async refresh({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
providerId,
|
||||
productId,
|
||||
conditionId,
|
||||
}) {
|
||||
const provider = pricingProviderRegistry?.get(providerId);
|
||||
if (!provider) {
|
||||
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503);
|
||||
throw createApplicationError(
|
||||
"PRICING_PROVIDER_UNAVAILABLE",
|
||||
"Pricing provider is not configured.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
let providerObservations;
|
||||
try {
|
||||
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
|
||||
providerObservations = await provider.observe({
|
||||
inventoryItemId,
|
||||
productId,
|
||||
conditionId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
|
||||
throw createApplicationError(
|
||||
"PRICING_PROVIDER_FAILED",
|
||||
error.message,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const observations = [];
|
||||
for (const input of providerObservations) {
|
||||
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId }));
|
||||
observations.push(
|
||||
await this.addObservation({
|
||||
sellerId,
|
||||
...input,
|
||||
providerId: provider.id,
|
||||
inventoryItemId,
|
||||
productId,
|
||||
conditionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) };
|
||||
return {
|
||||
observations,
|
||||
valuation: await this.calculate({ sellerId, inventoryItemId }),
|
||||
};
|
||||
},
|
||||
|
||||
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
||||
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
|
||||
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId);
|
||||
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409);
|
||||
async enqueueRefresh({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
providerId,
|
||||
productId,
|
||||
conditionId,
|
||||
}) {
|
||||
if (!pricingRefreshJobRepository)
|
||||
throw createApplicationError(
|
||||
"PRICING_QUEUE_UNAVAILABLE",
|
||||
"Pricing refresh queue is unavailable.",
|
||||
503,
|
||||
);
|
||||
const mapping = await providerMappingRepository?.findActiveForSeller(
|
||||
productId,
|
||||
sellerId,
|
||||
providerId,
|
||||
);
|
||||
if (!mapping)
|
||||
throw createApplicationError(
|
||||
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
|
||||
"No active pricing provider mapping exists for this product.",
|
||||
409,
|
||||
);
|
||||
const job = await pricingRefreshJobRepository.create({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
providerId: mapping.providerId,
|
||||
externalIdentifier: mapping.externalIdentifier,
|
||||
searchQuery: mapping.searchQuery,
|
||||
productId,
|
||||
conditionId,
|
||||
});
|
||||
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
||||
},
|
||||
|
||||
async getJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId);
|
||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
|
||||
const job = await pricingRefreshJobRepository?.findByIdForSeller(
|
||||
jobId,
|
||||
sellerId,
|
||||
);
|
||||
if (!job)
|
||||
throw createApplicationError(
|
||||
"PRICING_JOB_NOT_FOUND",
|
||||
"Pricing job was not found.",
|
||||
404,
|
||||
);
|
||||
return job;
|
||||
},
|
||||
|
||||
@@ -77,9 +203,21 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
||||
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
|
||||
},
|
||||
|
||||
async listValuations({ sellerId }) {
|
||||
return pricingRepository.listLatestValuationsForSeller(sellerId);
|
||||
},
|
||||
|
||||
async retryJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
|
||||
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(
|
||||
jobId,
|
||||
sellerId,
|
||||
);
|
||||
if (!job)
|
||||
throw createApplicationError(
|
||||
"PRICING_JOB_NOT_RETRYABLE",
|
||||
"Pricing job was not found or is not failed.",
|
||||
409,
|
||||
);
|
||||
return job;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createProviderMappingService({ providerMappingRepository }) {
|
||||
return {
|
||||
async create({ sellerId, ...input }) {
|
||||
const result = await providerMappingRepository.createForSeller(input, sellerId);
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404);
|
||||
const result = await providerMappingRepository.createForSeller(
|
||||
input,
|
||||
sellerId,
|
||||
);
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"PRODUCT_NOT_FOUND",
|
||||
"Product was not found.",
|
||||
404,
|
||||
);
|
||||
return result.mapping;
|
||||
},
|
||||
async list({ productId, sellerId }) {
|
||||
return providerMappingRepository.listForSeller(productId, sellerId);
|
||||
},
|
||||
async disable({ id, sellerId }) {
|
||||
const result = await providerMappingRepository.disableForSeller(id, sellerId);
|
||||
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404);
|
||||
const result = await providerMappingRepository.disableForSeller(
|
||||
id,
|
||||
sellerId,
|
||||
);
|
||||
if (result.status === "NOT_FOUND")
|
||||
throw createApplicationError(
|
||||
"PROVIDER_MAPPING_NOT_FOUND",
|
||||
"Provider mapping was not found.",
|
||||
404,
|
||||
);
|
||||
return result.mapping;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,11 +6,13 @@ function requiredText(value, fieldName) {
|
||||
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 {
|
||||
name: requiredText(name, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
tags: normalizedTags,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createProductRepository(db) {
|
||||
@@ -24,10 +26,16 @@ export function createProductRepository(db) {
|
||||
|
||||
async findPublicBySlug(slug) {
|
||||
const [result] = await db
|
||||
.select({ product: products })
|
||||
.select({
|
||||
product: products,
|
||||
sellerDisplayName: sellers.displayName,
|
||||
sellerSettings: sellerSettings.settings,
|
||||
})
|
||||
.from(products)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.productId, products.id))
|
||||
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
|
||||
.innerJoin(sellers, eq(sellers.id, products.sellerId))
|
||||
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
|
||||
.where(and(
|
||||
eq(products.slug, slug),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
@@ -35,7 +43,14 @@ export function createProductRepository(db) {
|
||||
))
|
||||
.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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
|
||||
const [, year, month, day] = match;
|
||||
const parsedDate = new Date(`${date}T00:00:00.000Z`);
|
||||
if (
|
||||
Number.isNaN(parsedDate.getTime())
|
||||
|| parsedDate.getUTCFullYear() !== Number(year)
|
||||
|| parsedDate.getUTCMonth() + 1 !== Number(month)
|
||||
|| parsedDate.getUTCDate() !== Number(day)
|
||||
Number.isNaN(parsedDate.getTime()) ||
|
||||
parsedDate.getUTCFullYear() !== Number(year) ||
|
||||
parsedDate.getUTCMonth() + 1 !== Number(month) ||
|
||||
parsedDate.getUTCDate() !== Number(day)
|
||||
) {
|
||||
throw new Error(`${fieldName} must be a valid ISO date.`);
|
||||
}
|
||||
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
|
||||
}
|
||||
|
||||
function requireCurrency(value) {
|
||||
const currency = requiredText(value, 'Currency').toUpperCase();
|
||||
const currency = requiredText(value, "Currency").toUpperCase();
|
||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||
throw new Error('Currency must be a three-letter ISO currency code.');
|
||||
throw new Error("Currency must be a three-letter ISO currency code.");
|
||||
}
|
||||
|
||||
return currency;
|
||||
@@ -58,7 +58,7 @@ export function createSale({
|
||||
saleDate,
|
||||
saleLocation,
|
||||
soldPriceMinor,
|
||||
currency = 'USD',
|
||||
currency = "USD",
|
||||
platformFeesMinor = 0,
|
||||
paymentProcessingFeesMinor = 0,
|
||||
shippingCostMinor = 0,
|
||||
@@ -69,42 +69,55 @@ export function createSale({
|
||||
notes,
|
||||
}) {
|
||||
return {
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
saleDate: requireIsoDate(saleDate, 'Sale date'),
|
||||
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
|
||||
sellerId: requiredText(sellerId, "Seller ID"),
|
||||
saleDate: requireIsoDate(saleDate, "Sale date"),
|
||||
...(typeof saleLocation === "string" && saleLocation.trim().length > 0
|
||||
? { saleLocation: saleLocation.trim() }
|
||||
: {}),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||
currency: requireCurrency(currency),
|
||||
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
|
||||
platformFeesMinor: requireNonNegativeMinorUnit(
|
||||
platformFeesMinor,
|
||||
"Platform fees",
|
||||
),
|
||||
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
||||
paymentProcessingFeesMinor,
|
||||
'Payment processing fees',
|
||||
"Payment processing fees",
|
||||
),
|
||||
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'),
|
||||
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'),
|
||||
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'),
|
||||
...(typeof buyerName === 'string' && buyerName.trim().length > 0
|
||||
shippingCostMinor: requireNonNegativeMinorUnit(
|
||||
shippingCostMinor,
|
||||
"Shipping cost",
|
||||
),
|
||||
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
|
||||
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
|
||||
...(typeof buyerName === "string" && buyerName.trim().length > 0
|
||||
? { buyerName: buyerName.trim() }
|
||||
: {}),
|
||||
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0
|
||||
...(typeof buyerContact === "string" && buyerContact.trim().length > 0
|
||||
? { buyerContact: buyerContact.trim() }
|
||||
: {}),
|
||||
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}),
|
||||
...(typeof notes === "string" && notes.trim().length > 0
|
||||
? { notes: notes.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
|
||||
return {
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
|
||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
|
||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
|
||||
export function createSaleItem({
|
||||
saleId,
|
||||
inventoryItemId,
|
||||
soldPriceMinor,
|
||||
costBasisMinor,
|
||||
}) {
|
||||
return {
|
||||
saleId: requiredText(saleId, 'Sale ID'),
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
saleId: requiredText(saleId, "Sale ID"),
|
||||
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
|
||||
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
|
||||
};
|
||||
}
|
||||
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
|
||||
taxesMinor = 0,
|
||||
otherFeesMinor = 0,
|
||||
}) {
|
||||
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price');
|
||||
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees');
|
||||
const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
|
||||
const platformFees = requireNonNegativeMinorUnit(
|
||||
platformFeesMinor,
|
||||
"Platform fees",
|
||||
);
|
||||
const processingFees = requireNonNegativeMinorUnit(
|
||||
paymentProcessingFeesMinor,
|
||||
'Payment processing fees',
|
||||
"Payment processing fees",
|
||||
);
|
||||
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost');
|
||||
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes');
|
||||
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees');
|
||||
const shipping = requireNonNegativeMinorUnit(
|
||||
shippingCostMinor,
|
||||
"Shipping cost",
|
||||
);
|
||||
const taxes = requireNonNegativeMinorUnit(taxesMinor, "Taxes");
|
||||
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, "Other fees");
|
||||
|
||||
return salePrice - platformFees - processingFees - shipping - taxes - otherFees;
|
||||
return (
|
||||
salePrice - platformFees - processingFees - shipping - taxes - otherFees
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
inventoryItems,
|
||||
@@ -6,14 +6,20 @@ import {
|
||||
saleItems,
|
||||
sales,
|
||||
securityAuditEvents,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createSaleRepository(db) {
|
||||
return {
|
||||
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
|
||||
return this.createForInventoryItems({
|
||||
sale,
|
||||
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }],
|
||||
items: [
|
||||
{
|
||||
inventoryItemId,
|
||||
soldPriceMinor: sale.soldPriceMinor,
|
||||
costBasisMinor,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
@@ -23,33 +29,57 @@ export function createSaleRepository(db) {
|
||||
.select({ id: inventoryItems.id, status: inventoryItems.status })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
inventoryItems.id,
|
||||
items.map((item) => item.inventoryItemId),
|
||||
),
|
||||
eq(products.sellerId, sale.sellerId),
|
||||
));
|
||||
),
|
||||
);
|
||||
|
||||
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' };
|
||||
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE');
|
||||
if (ownedInventoryItems.length !== items.length)
|
||||
return { status: "NOT_FOUND" };
|
||||
const unavailableItem = ownedInventoryItems.find(
|
||||
(item) => item.status !== "ACTIVE",
|
||||
);
|
||||
if (unavailableItem) {
|
||||
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status };
|
||||
return {
|
||||
status: "NOT_ACTIVE",
|
||||
inventoryStatus: unavailableItem.status,
|
||||
};
|
||||
}
|
||||
|
||||
const [createdSale] = await transaction.insert(sales).values(sale).returning();
|
||||
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({
|
||||
const [createdSale] = await transaction
|
||||
.insert(sales)
|
||||
.values(sale)
|
||||
.returning();
|
||||
const createdSaleItems = await transaction
|
||||
.insert(saleItems)
|
||||
.values(
|
||||
items.map((item) => ({
|
||||
saleId: createdSale.id,
|
||||
...item,
|
||||
}))).returning();
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
const updatedInventoryItems = await transaction
|
||||
.update(inventoryItems)
|
||||
.set({ status: 'SOLD', updatedAt: new Date() })
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
))
|
||||
.set({ status: "SOLD", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
inventoryItems.id,
|
||||
items.map((item) => item.inventoryItemId),
|
||||
),
|
||||
eq(inventoryItems.status, "ACTIVE"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return {
|
||||
status: 'CREATED',
|
||||
status: "CREATED",
|
||||
sale: createdSale,
|
||||
saleItems: createdSaleItems,
|
||||
saleItem: createdSaleItems[0],
|
||||
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
|
||||
})
|
||||
.from(sales)
|
||||
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, saleItems.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(sales.sellerId, sellerId))
|
||||
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
|
||||
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
|
||||
productNames: [],
|
||||
};
|
||||
current.saleItems.push(row.saleItem);
|
||||
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus });
|
||||
current.inventoryItems.push({
|
||||
id: row.saleItem.inventoryItemId,
|
||||
status: row.inventoryStatus,
|
||||
});
|
||||
current.productNames.push(row.productName);
|
||||
grouped.set(row.sale.id, current);
|
||||
}
|
||||
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
|
||||
...sale,
|
||||
saleItem: sale.saleItems[0],
|
||||
inventoryItem: sale.inventoryItems[0],
|
||||
productName: sale.productNames.join(', '),
|
||||
productName: sale.productNames.join(", "),
|
||||
}));
|
||||
},
|
||||
|
||||
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const saleRecords = await transaction
|
||||
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status })
|
||||
.select({
|
||||
sale: sales,
|
||||
saleItem: saleItems,
|
||||
inventoryStatus: inventoryItems.status,
|
||||
})
|
||||
.from(sales)
|
||||
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
eq(inventoryItems.id, saleItems.inventoryItemId),
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId)));
|
||||
.where(
|
||||
and(
|
||||
eq(sales.id, saleId),
|
||||
eq(sales.sellerId, sellerId),
|
||||
eq(products.sellerId, sellerId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!saleRecords.length) return { status: 'NOT_FOUND' };
|
||||
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' };
|
||||
if (!saleRecords.length) return { status: "NOT_FOUND" };
|
||||
if (saleRecords[0].sale.status === "VOIDED")
|
||||
return { status: "ALREADY_VOIDED" };
|
||||
const saleRecord = saleRecords[0];
|
||||
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId);
|
||||
const inventoryItemIds = saleRecords.map(
|
||||
({ saleItem }) => saleItem.inventoryItemId,
|
||||
);
|
||||
|
||||
const [updatedSale] = await transaction
|
||||
.update(sales)
|
||||
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED')))
|
||||
.set({
|
||||
status: "VOIDED",
|
||||
voidReason: reason,
|
||||
voidedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
|
||||
.returning();
|
||||
const [updatedInventoryItem] = await transaction
|
||||
.update(inventoryItems)
|
||||
.set({ status: 'ACTIVE', updatedAt: new Date() })
|
||||
.where(and(
|
||||
.set({ status: "ACTIVE", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
inArray(inventoryItems.id, inventoryItemIds),
|
||||
eq(inventoryItems.status, 'SOLD'),
|
||||
))
|
||||
eq(inventoryItems.status, "SOLD"),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
await transaction.insert(securityAuditEvents).values({
|
||||
userId: actorUserId,
|
||||
event: 'SALE_VOIDED',
|
||||
event: "SALE_VOIDED",
|
||||
metadata: { saleId, inventoryItemIds, reason },
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'VOIDED',
|
||||
status: "VOIDED",
|
||||
sale: updatedSale,
|
||||
saleItems: saleRecords.map(({ saleItem }) => saleItem),
|
||||
saleItem: saleRecord.saleItem,
|
||||
|
||||
@@ -1,47 +1,64 @@
|
||||
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
|
||||
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
|
||||
import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
|
||||
import {
|
||||
createMultiItemSaleSchema,
|
||||
createSaleSchema,
|
||||
voidSaleSchema,
|
||||
} from "@pokemon-inventory/validation/sale";
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed.",
|
||||
details: result.error.issues.map(({ message, path }) => ({
|
||||
message,
|
||||
path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerAdminSaleRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
csrfCookieName = "pokemon_inventory_csrf",
|
||||
sessionCookieName = "pokemon_inventory_session",
|
||||
saleService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
throw createApplicationError(
|
||||
"UNAUTHORIZED",
|
||||
"Authentication is required.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.post('/sales', async (request, reply) => {
|
||||
app.post("/sales", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
|
||||
const schema = Array.isArray(request.body?.items)
|
||||
? createMultiItemSaleSchema
|
||||
: createSaleSchema;
|
||||
const result = schema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
|
||||
return reply.code(201).send({ data: toAdminSaleDto(sale) });
|
||||
});
|
||||
|
||||
app.get('/sales', async (request) => ({
|
||||
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
|
||||
app.get("/sales", async (request) => ({
|
||||
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
|
||||
toAdminSaleDto,
|
||||
),
|
||||
}));
|
||||
|
||||
app.post('/sales/:id/void', async (request, reply) => {
|
||||
app.post("/sales/:id/void", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
request.headers["x-csrf-token"],
|
||||
);
|
||||
const result = voidSaleSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import {
|
||||
calculateNetSaleProceeds,
|
||||
createSale,
|
||||
validateSaleItem,
|
||||
} from "../domain/sale.js";
|
||||
import { createApplicationError } from "../../../shared/errors/application-error.js";
|
||||
|
||||
export function createSaleService({ saleRepository }) {
|
||||
return {
|
||||
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
|
||||
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
|
||||
async create({
|
||||
sellerId,
|
||||
inventoryItemId,
|
||||
costBasisMinor,
|
||||
items,
|
||||
...input
|
||||
}) {
|
||||
const saleItems = items ?? [
|
||||
{
|
||||
inventoryItemId,
|
||||
soldPriceMinor: input.soldPriceMinor,
|
||||
costBasisMinor,
|
||||
},
|
||||
];
|
||||
const totalSoldPriceMinor = items
|
||||
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
|
||||
: input.soldPriceMinor;
|
||||
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
|
||||
const sale = createSale({
|
||||
sellerId,
|
||||
...input,
|
||||
soldPriceMinor: totalSoldPriceMinor,
|
||||
});
|
||||
const validatedItems = saleItems.map((item) => ({
|
||||
inventoryItemId: item.inventoryItemId,
|
||||
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
|
||||
costBasisMinor: validateSaleItem(item).costBasisMinor,
|
||||
}));
|
||||
const result = items
|
||||
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
|
||||
? await saleRepository.createForInventoryItems({
|
||||
sale,
|
||||
items: validatedItems,
|
||||
})
|
||||
: await saleRepository.createForInventoryItem({
|
||||
sale,
|
||||
inventoryItemId,
|
||||
costBasisMinor: validatedItems[0].costBasisMinor,
|
||||
});
|
||||
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"INVENTORY_NOT_FOUND",
|
||||
"Inventory item was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === 'NOT_ACTIVE') {
|
||||
if (result.status === "NOT_ACTIVE") {
|
||||
throw createApplicationError(
|
||||
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
|
||||
'Inventory item is not available for sale.',
|
||||
"INVENTORY_NOT_AVAILABLE_FOR_SALE",
|
||||
"Inventory item is not available for sale.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
|
||||
},
|
||||
|
||||
async void({ saleId, sellerId, actorUserId, reason }) {
|
||||
if (typeof reason !== 'string' || reason.trim().length < 3) {
|
||||
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
|
||||
if (typeof reason !== "string" || reason.trim().length < 3) {
|
||||
throw createApplicationError(
|
||||
"SALE_VOID_REASON_REQUIRED",
|
||||
"A void reason is required.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await saleRepository.voidForSeller({
|
||||
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
|
||||
actorUserId,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
|
||||
if (result.status === "NOT_FOUND") {
|
||||
throw createApplicationError(
|
||||
"SALE_NOT_FOUND",
|
||||
"Sale was not found.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'ALREADY_VOIDED') {
|
||||
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
|
||||
if (result.status === "ALREADY_VOIDED") {
|
||||
throw createApplicationError(
|
||||
"SALE_ALREADY_VOIDED",
|
||||
"Sale is already voided.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return withFinancialSummary(result);
|
||||
@@ -71,6 +110,8 @@ function withFinancialSummary(result) {
|
||||
return {
|
||||
...result,
|
||||
netProceedsMinor,
|
||||
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
|
||||
profitMinor:
|
||||
netProceedsMinor -
|
||||
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
function requiredString(value, fieldName) {
|
||||
if (typeof value !== 'string') {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${fieldName} must be a string.`);
|
||||
}
|
||||
|
||||
@@ -7,71 +7,105 @@ function requiredString(value, fieldName) {
|
||||
}
|
||||
|
||||
export function normalizeSearchQuery(value) {
|
||||
return requiredString(value, 'Search query')
|
||||
return requiredString(value, "Search query")
|
||||
.trim()
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.normalize("NFKC")
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function createSearchQuery({
|
||||
query = '',
|
||||
query = "",
|
||||
category,
|
||||
conditionId,
|
||||
setName,
|
||||
eventName,
|
||||
tag,
|
||||
status,
|
||||
newlyAdded = false,
|
||||
minPriceMinor,
|
||||
maxPriceMinor,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
includeSold = false,
|
||||
sort = 'recent',
|
||||
sort = "recent",
|
||||
}) {
|
||||
const text = normalizeSearchQuery(query);
|
||||
|
||||
if (!Number.isInteger(page) || page < 1) {
|
||||
throw new Error('page must be >= 1');
|
||||
throw new Error("page must be >= 1");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new Error('limit must be between 1 and 100');
|
||||
throw new Error("limit must be between 1 and 100");
|
||||
}
|
||||
|
||||
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) {
|
||||
throw new Error('minPriceMinor must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
|
||||
throw new Error('maxPriceMinor must be a non-negative integer');
|
||||
if (status && !["AVAILABLE", "PENDING", "SOLD", "ALL"].includes(status)) {
|
||||
throw new Error("status must be AVAILABLE, PENDING, SOLD, or ALL");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof minPriceMinor !== 'undefined' &&
|
||||
typeof maxPriceMinor !== 'undefined' &&
|
||||
minPriceMinor > maxPriceMinor
|
||||
typeof minPriceMinor !== "undefined" &&
|
||||
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
||||
) {
|
||||
throw new Error('minPriceMinor must be <= maxPriceMinor');
|
||||
throw new Error("minPriceMinor must be a non-negative integer");
|
||||
}
|
||||
|
||||
if (!['recent', 'price_low', 'price_high'].includes(sort)) {
|
||||
throw new Error('sort must be recent, price_low, or price_high');
|
||||
if (
|
||||
typeof maxPriceMinor !== "undefined" &&
|
||||
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
|
||||
) {
|
||||
throw new Error("maxPriceMinor must be a non-negative integer");
|
||||
}
|
||||
|
||||
if (
|
||||
typeof minPriceMinor !== "undefined" &&
|
||||
typeof maxPriceMinor !== "undefined" &&
|
||||
minPriceMinor > maxPriceMinor
|
||||
) {
|
||||
throw new Error("minPriceMinor must be <= maxPriceMinor");
|
||||
}
|
||||
|
||||
if (!["recent", "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 {
|
||||
text,
|
||||
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}),
|
||||
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}),
|
||||
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}),
|
||||
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}),
|
||||
...(typeof category === "string" && category.trim().length > 0
|
||||
? { category: category.trim() }
|
||||
: {}),
|
||||
...(typeof conditionId === "string" && conditionId.trim().length > 0
|
||||
? { conditionId: conditionId.trim() }
|
||||
: {}),
|
||||
...(typeof 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,
|
||||
limit,
|
||||
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 {
|
||||
status: includeSold ? 'ALL' : status,
|
||||
status: includeSold ? "ALL" : status,
|
||||
includeSold,
|
||||
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) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
|
||||
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400);
|
||||
throw createApplicationError(
|
||||
"INVALID_SEARCH_FILTER",
|
||||
`${fieldName} must be an integer.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
|
||||
export async function registerPublicSearchRoutes(app, options) {
|
||||
const { searchService } = options;
|
||||
|
||||
app.get('/public/search', async (request) => {
|
||||
if (!searchService || typeof searchService.searchPublic !== 'function') {
|
||||
app.get("/public/search", async (request) => {
|
||||
if (!searchService || typeof searchService.searchPublic !== "function") {
|
||||
throw createApplicationError(
|
||||
'SEARCH_SERVICE_UNAVAILABLE',
|
||||
'Public search is currently unavailable.',
|
||||
"SEARCH_SERVICE_UNAVAILABLE",
|
||||
"Public search is currently unavailable.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
@@ -28,12 +32,27 @@ export async function registerPublicSearchRoutes(app, options) {
|
||||
query: request.query.query,
|
||||
category: request.query.category,
|
||||
conditionId: request.query.conditionId,
|
||||
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined),
|
||||
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined),
|
||||
page: coerceNumber(request.query.page, 'page', 1),
|
||||
limit: coerceNumber(request.query.limit, 'limit', 20),
|
||||
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true,
|
||||
sort: request.query.sort ?? 'recent',
|
||||
setName: request.query.setName,
|
||||
eventName: request.query.eventName,
|
||||
tag: request.query.tag,
|
||||
status: request.query.status,
|
||||
newlyAdded: request.query.newlyAdded === "true" || request.query.newlyAdded === true,
|
||||
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) };
|
||||
|
||||
@@ -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 test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js';
|
||||
import { createAcquisitionService } from "../../src/modules/acquisitions/services/acquisition-service.js";
|
||||
|
||||
test('creates an owned acquisition and calculates cost basis', async () => {
|
||||
test("creates an owned acquisition and calculates cost basis", async () => {
|
||||
let received;
|
||||
const service = createAcquisitionService({
|
||||
acquisitionRepository: {
|
||||
async createForSeller(input) {
|
||||
received = input;
|
||||
return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } };
|
||||
return {
|
||||
status: "CREATED",
|
||||
acquisition: { id: "acquisition-1", ...input.acquisition },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
sellerId: 'seller-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
method: 'PURCHASE',
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
method: "PURCHASE",
|
||||
costMinor: 1000,
|
||||
shippingMinor: 150,
|
||||
taxMinor: 75,
|
||||
@@ -25,17 +28,27 @@ test('creates an owned acquisition and calculates cost basis', async () => {
|
||||
estimatedTradeValueMinor: 200,
|
||||
});
|
||||
|
||||
assert.equal(received.sellerId, 'seller-1');
|
||||
assert.equal(received.sellerId, "seller-1");
|
||||
assert.equal(result.costBasisMinor, 1075);
|
||||
});
|
||||
|
||||
test('rejects acquisition for an inventory item outside the seller account', async () => {
|
||||
test("rejects acquisition for an inventory item outside the seller account", async () => {
|
||||
const service = createAcquisitionService({
|
||||
acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } },
|
||||
acquisitionRepository: {
|
||||
async createForSeller() {
|
||||
return { status: "NOT_FOUND" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }),
|
||||
(error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404,
|
||||
() =>
|
||||
service.create({
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
method: "PURCHASE",
|
||||
costMinor: 100,
|
||||
}),
|
||||
(error) => error.code === "INVENTORY_NOT_FOUND" && error.statusCode === 404,
|
||||
);
|
||||
});
|
||||
@@ -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 <note>/);
|
||||
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 test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js';
|
||||
import { createDashboardService } from "../../src/modules/dashboard/services/dashboard-service.js";
|
||||
|
||||
test('aggregates seller dashboard counts and financial totals', async () => {
|
||||
test("aggregates seller dashboard counts and financial totals", async () => {
|
||||
const service = createDashboardService({
|
||||
productService: { async listBySellerId() { return [{ id: 'product-1' }]; } },
|
||||
inventoryItemService: { async listBySellerId() { return [{ id: 'item-1', status: 'ACTIVE' }, { id: 'item-2', status: 'SOLD' }]; } },
|
||||
listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } },
|
||||
saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } },
|
||||
acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } },
|
||||
pricingRepository: { async listLatestValuationsForSeller() { return [{ inventoryItemId: 'item-1', valueMinor: 2800, calculatedAt: new Date() }]; } },
|
||||
pricingRefreshJobRepository: { async countFailedForSeller() { return 2; } },
|
||||
productService: {
|
||||
async listBySellerId() {
|
||||
return [{ id: "product-1" }];
|
||||
},
|
||||
},
|
||||
inventoryItemService: {
|
||||
async listBySellerId() {
|
||||
return [
|
||||
{ id: "item-1", status: "ACTIVE" },
|
||||
{ id: "item-2", status: "SOLD" },
|
||||
];
|
||||
},
|
||||
},
|
||||
listingService: {
|
||||
async list() {
|
||||
return [{ status: "AVAILABLE", askingPriceMinor: 2500, imageCount: 1 }];
|
||||
},
|
||||
},
|
||||
saleService: {
|
||||
async list() {
|
||||
return [
|
||||
{ status: "COMPLETED", netProceedsMinor: 2200, profitMinor: 700 },
|
||||
];
|
||||
},
|
||||
},
|
||||
acquisitionService: {
|
||||
async list() {
|
||||
return [{ costBasisMinor: 1500 }];
|
||||
},
|
||||
},
|
||||
pricingRepository: {
|
||||
async listLatestValuationsForSeller() {
|
||||
return [
|
||||
{
|
||||
inventoryItemId: "item-1",
|
||||
valueMinor: 2800,
|
||||
calculatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
pricingRefreshJobRepository: {
|
||||
async countFailedForSeller() {
|
||||
return 2;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dashboard = await service.get({ sellerId: 'seller-1' });
|
||||
const dashboard = await service.get({ sellerId: "seller-1" });
|
||||
assert.deepEqual(dashboard.counts, {
|
||||
products: 1,
|
||||
inventory: 2,
|
||||
|
||||
@@ -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 === '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 jobs = [];
|
||||
const service = createInventoryImageService({
|
||||
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
|
||||
imageStorage: {
|
||||
async put(value) { stored.push(value); },
|
||||
async delete() {},
|
||||
@@ -38,6 +39,9 @@ test('stores validated upload bytes before persisting image metadata', async ()
|
||||
assert.equal(stored.length, 1);
|
||||
assert.deepEqual(stored[0], { key: input.storageKey, data: input.data });
|
||||
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('data' in image, false);
|
||||
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 () => {
|
||||
const deleted = [];
|
||||
const service = createInventoryImageService({
|
||||
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
|
||||
imageStorage: {
|
||||
async put() {},
|
||||
async delete(key) { deleted.push(key); },
|
||||
@@ -65,3 +70,20 @@ test('removes stored bytes when metadata persistence fails', async () => {
|
||||
);
|
||||
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({
|
||||
productRepository: { async findById() { return product; } },
|
||||
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 () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
|
||||
@@ -1,38 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createPricingProvider, createPricingProviderRegistry } from '../../src/modules/pricing/providers/pricing-provider.js';
|
||||
import { createFixturePricingProvider } from '../../src/modules/pricing/providers/fixture-pricing-provider.js';
|
||||
import {
|
||||
createPricingProvider,
|
||||
createPricingProviderRegistry,
|
||||
} from "../../src/modules/pricing/providers/pricing-provider.js";
|
||||
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
|
||||
import {
|
||||
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({
|
||||
id: 'example',
|
||||
async observe() { return [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]; },
|
||||
id: "example",
|
||||
async observe() {
|
||||
return [{ priceMinor: 2500, sourceIdentifier: "example-1" }];
|
||||
},
|
||||
});
|
||||
const registry = createPricingProviderRegistry([provider]);
|
||||
|
||||
assert.equal(registry.get('example'), provider);
|
||||
assert.deepEqual(await registry.get('example').observe({}), [{ priceMinor: 2500, sourceIdentifier: 'example-1' }]);
|
||||
assert.equal(registry.get("example"), provider);
|
||||
assert.deepEqual(await registry.get("example").observe({}), [
|
||||
{ priceMinor: 2500, sourceIdentifier: "example-1" },
|
||||
]);
|
||||
assert.deepEqual(registry.list(), [provider]);
|
||||
});
|
||||
|
||||
test('rejects malformed provider adapters and responses', async () => {
|
||||
assert.throws(() => createPricingProvider({ id: 'broken' }), /observe function is required/);
|
||||
const provider = createPricingProvider({ id: 'broken', async observe() { return null; } });
|
||||
await assert.rejects(() => provider.observe({}), /must return an observation array/);
|
||||
test("rejects malformed provider adapters and responses", async () => {
|
||||
assert.throws(
|
||||
() => createPricingProvider({ id: "broken" }),
|
||||
/observe function is required/,
|
||||
);
|
||||
const provider = createPricingProvider({
|
||||
id: "broken",
|
||||
async observe() {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => provider.observe({}),
|
||||
/must return an observation array/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fixture provider returns deterministic normalized observations', async () => {
|
||||
test("fixture provider returns deterministic normalized observations", async () => {
|
||||
const [observation] = await createFixturePricingProvider().observe({
|
||||
productId: 'product-1', inventoryItemId: 'item-1', conditionId: 'condition-1',
|
||||
productId: "product-1",
|
||||
inventoryItemId: "item-1",
|
||||
conditionId: "condition-1",
|
||||
});
|
||||
assert.deepEqual(observation, {
|
||||
productId: 'product-1',
|
||||
inventoryItemId: 'item-1',
|
||||
conditionId: 'condition-1',
|
||||
productId: "product-1",
|
||||
inventoryItemId: "item-1",
|
||||
conditionId: "condition-1",
|
||||
priceMinor: 2500,
|
||||
currency: 'USD',
|
||||
sourceIdentifier: 'fixture-item-1',
|
||||
currency: "USD",
|
||||
sourceIdentifier: "fixture-item-1",
|
||||
qualityScore: 50,
|
||||
});
|
||||
});
|
||||
|
||||
test("http provider 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 test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createPricingService } from '../../src/modules/pricing/services/pricing-service.js';
|
||||
import { createPricingService } from "../../src/modules/pricing/services/pricing-service.js";
|
||||
|
||||
test('records an owned pricing observation and calculates a valuation', async () => {
|
||||
test("records an owned pricing observation and calculates a valuation", async () => {
|
||||
const service = createPricingService({
|
||||
pricingRepository: {
|
||||
async addObservationForSeller({ observation, sellerId }) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return { status: 'CREATED', observation };
|
||||
assert.equal(sellerId, "seller-1");
|
||||
return { status: "CREATED", observation };
|
||||
},
|
||||
async markOutliersForSeller() {
|
||||
return { status: 'UPDATED' };
|
||||
return { status: "UPDATED" };
|
||||
},
|
||||
async calculateForSeller() {
|
||||
return { status: 'CALCULATED', valuation: { valueMinor: 2500 }, observations: [] };
|
||||
return {
|
||||
status: "CALCULATED",
|
||||
valuation: { valueMinor: 2500 },
|
||||
observations: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const observation = await service.addObservation({
|
||||
sellerId: 'seller-1', productId: 'product-1', inventoryItemId: 'inventory-1',
|
||||
providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'condition-1',
|
||||
sellerId: "seller-1",
|
||||
productId: "product-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
providerId: "manual",
|
||||
priceMinor: 2500,
|
||||
sourceIdentifier: "source-1",
|
||||
conditionId: "condition-1",
|
||||
});
|
||||
assert.equal(observation.priceMinor, 2500);
|
||||
const valuation = await service.calculate({ sellerId: 'seller-1', inventoryItemId: 'inventory-1' });
|
||||
const valuation = await service.calculate({
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
});
|
||||
assert.equal(valuation.valuation.valueMinor, 2500);
|
||||
});
|
||||
|
||||
test('requires a reason for an audited market-value override', async () => {
|
||||
const service = createPricingService({ pricingRepository: { async createOverride() { throw new Error('should not run'); } } });
|
||||
test("requires a reason for an audited market-value override", async () => {
|
||||
const service = createPricingService({
|
||||
pricingRepository: {
|
||||
async createOverride() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.override({ sellerId: 'seller-1', actorUserId: 'user-1', inventoryItemId: 'inventory-1', valueMinor: 2000, reason: 'x' }),
|
||||
(error) => error.code === 'OVERRIDE_REASON_REQUIRED' && error.statusCode === 400,
|
||||
() =>
|
||||
service.override({
|
||||
sellerId: "seller-1",
|
||||
actorUserId: "user-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
valueMinor: 2000,
|
||||
reason: "x",
|
||||
}),
|
||||
(error) =>
|
||||
error.code === "OVERRIDE_REASON_REQUIRED" && error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails clearly when a requested pricing provider is unavailable', async () => {
|
||||
test("fails clearly when a requested pricing provider is unavailable", async () => {
|
||||
const service = createPricingService({
|
||||
pricingRepository: {},
|
||||
pricingProviderRegistry: { get() { return null; } },
|
||||
pricingProviderRegistry: {
|
||||
get() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.refresh({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', providerId: 'missing' }),
|
||||
(error) => error.code === 'PRICING_PROVIDER_UNAVAILABLE' && error.statusCode === 503,
|
||||
() =>
|
||||
service.refresh({
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
providerId: "missing",
|
||||
}),
|
||||
(error) =>
|
||||
error.code === "PRICING_PROVIDER_UNAVAILABLE" && error.statusCode === 503,
|
||||
);
|
||||
});
|
||||
|
||||
test('enqueues a seller-scoped pricing refresh and reads its status', async () => {
|
||||
test("enqueues a seller-scoped pricing refresh and reads its status", async () => {
|
||||
const service = createPricingService({
|
||||
pricingRepository: {},
|
||||
pricingRefreshJobRepository: {
|
||||
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; },
|
||||
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; },
|
||||
async create(input) {
|
||||
return { id: "job-1", status: "PENDING", ...input };
|
||||
},
|
||||
async findByIdForSeller(id, sellerId) {
|
||||
return { id, sellerId, status: "COMPLETED" };
|
||||
},
|
||||
},
|
||||
providerMappingRepository: {
|
||||
async findActiveForSeller() {
|
||||
return { providerId: "mapped-provider" };
|
||||
},
|
||||
},
|
||||
providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } },
|
||||
});
|
||||
|
||||
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' });
|
||||
assert.equal(job.status, 'PENDING');
|
||||
assert.equal(job.providerId, 'mapped-provider');
|
||||
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED');
|
||||
const job = await service.enqueueRefresh({
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "item-1",
|
||||
providerId: "provider-1",
|
||||
productId: "product-1",
|
||||
conditionId: "condition-1",
|
||||
});
|
||||
assert.equal(job.status, "PENDING");
|
||||
assert.equal(job.providerId, "mapped-provider");
|
||||
assert.equal(
|
||||
(await service.getJob({ sellerId: "seller-1", jobId: job.id })).status,
|
||||
"COMPLETED",
|
||||
);
|
||||
});
|
||||
|
||||
test('lists and retries seller-scoped failed pricing jobs', async () => {
|
||||
test("lists and retries seller-scoped failed pricing jobs", async () => {
|
||||
const service = createPricingService({
|
||||
pricingRepository: {},
|
||||
pricingRefreshJobRepository: {
|
||||
async listForSeller() { return [{ id: 'job-1', status: 'FAILED' }]; },
|
||||
async retryForSeller() { return { id: 'job-1', status: 'PENDING' }; },
|
||||
async listForSeller() {
|
||||
return [{ id: "job-1", status: "FAILED" }];
|
||||
},
|
||||
async retryForSeller() {
|
||||
return { id: "job-1", status: "PENDING" };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await service.listJobs({ sellerId: 'seller-1' })).length, 1);
|
||||
assert.equal((await service.retryJob({ sellerId: 'seller-1', jobId: 'job-1' })).status, 'PENDING');
|
||||
assert.equal((await service.listJobs({ sellerId: "seller-1" })).length, 1);
|
||||
assert.equal(
|
||||
(await service.retryJob({ sellerId: "seller-1", jobId: "job-1" })).status,
|
||||
"PENDING",
|
||||
);
|
||||
});
|
||||
@@ -1,21 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createProviderMappingService } from '../../src/modules/pricing/services/provider-mapping-service.js';
|
||||
import { createProviderMappingService } from "../../src/modules/pricing/services/provider-mapping-service.js";
|
||||
|
||||
test('creates and disables seller-owned provider mappings', async () => {
|
||||
test("creates and disables seller-owned provider mappings", async () => {
|
||||
const calls = [];
|
||||
const service = createProviderMappingService({
|
||||
providerMappingRepository: {
|
||||
async createForSeller(input, sellerId) { calls.push(['create', input, sellerId]); return { status: 'CREATED', mapping: { id: 'mapping-1', ...input } }; },
|
||||
async listForSeller() { return [{ id: 'mapping-1', status: 'ACTIVE' }]; },
|
||||
async disableForSeller() { return { status: 'DISABLED', mapping: { id: 'mapping-1', status: 'DISABLED' } }; },
|
||||
async createForSeller(input, sellerId) {
|
||||
calls.push(["create", input, sellerId]);
|
||||
return { status: "CREATED", mapping: { id: "mapping-1", ...input } };
|
||||
},
|
||||
async listForSeller() {
|
||||
return [{ id: "mapping-1", status: "ACTIVE" }];
|
||||
},
|
||||
async disableForSeller() {
|
||||
return {
|
||||
status: "DISABLED",
|
||||
mapping: { id: "mapping-1", status: "DISABLED" },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.create({ sellerId: 'seller-1', productId: 'product-1', providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' });
|
||||
assert.equal(created.id, 'mapping-1');
|
||||
assert.equal(calls[0][2], 'seller-1');
|
||||
assert.equal((await service.list({ sellerId: 'seller-1', productId: 'product-1' })).length, 1);
|
||||
assert.equal((await service.disable({ sellerId: 'seller-1', id: 'mapping-1' })).status, 'DISABLED');
|
||||
const created = await service.create({
|
||||
sellerId: "seller-1",
|
||||
productId: "product-1",
|
||||
providerId: "fixture",
|
||||
mappingType: "PRODUCT",
|
||||
externalIdentifier: "ext-1",
|
||||
});
|
||||
assert.equal(created.id, "mapping-1");
|
||||
assert.equal(calls[0][2], "seller-1");
|
||||
assert.equal(
|
||||
(await service.list({ sellerId: "seller-1", productId: "product-1" }))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(await service.disable({ sellerId: "seller-1", id: "mapping-1" })).status,
|
||||
"DISABLED",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createSaleService } from '../../src/modules/sales/services/sale-service.js';
|
||||
import { createSaleService } from "../../src/modules/sales/services/sale-service.js";
|
||||
|
||||
function validInput(overrides = {}) {
|
||||
return {
|
||||
sellerId: 'seller-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
saleDate: '2026-09-09',
|
||||
sellerId: "seller-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
saleDate: "2026-09-09",
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a sale through the repository with validated money values', async () => {
|
||||
test("creates a sale through the repository with validated money values", async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItem(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
status: "CREATED",
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
id: "sale-1",
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
@@ -38,9 +38,9 @@ test('creates a sale through the repository with validated money values', async
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.create(validInput()), {
|
||||
status: 'CREATED',
|
||||
status: "CREATED",
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
id: "sale-1",
|
||||
soldPriceMinor: 2499,
|
||||
platformFeesMinor: 100,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
@@ -52,85 +52,105 @@ test('creates a sale through the repository with validated money values', async
|
||||
netProceedsMinor: 2399,
|
||||
profitMinor: 1199,
|
||||
});
|
||||
assert.equal(received.sale.sellerId, 'seller-1');
|
||||
assert.equal(received.sale.sellerId, "seller-1");
|
||||
assert.equal(received.sale.soldPriceMinor, 2499);
|
||||
assert.equal(received.costBasisMinor, 1200);
|
||||
});
|
||||
|
||||
test('lists seller sales with derived net proceeds and profit', async () => {
|
||||
test("lists seller sales with derived net proceeds and profit", async () => {
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [{
|
||||
assert.equal(sellerId, "seller-1");
|
||||
return [
|
||||
{
|
||||
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
}];
|
||||
inventoryItem: { id: "inventory-1", status: "SOLD" },
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [sale] = await service.list({ sellerId: 'seller-1' });
|
||||
const [sale] = await service.list({ sellerId: "seller-1" });
|
||||
assert.equal(sale.netProceedsMinor, 2800);
|
||||
assert.equal(sale.profitMinor, 1300);
|
||||
});
|
||||
|
||||
test('voids an owned sale with a required reason', async () => {
|
||||
test("voids an owned sale with a required reason", async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async voidForSeller(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'VOIDED',
|
||||
sale: { id: 'sale-1', soldPriceMinor: 3000 },
|
||||
status: "VOIDED",
|
||||
sale: { id: "sale-1", soldPriceMinor: 3000 },
|
||||
saleItem: { costBasisMinor: 1500 },
|
||||
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' },
|
||||
inventoryItem: { id: "inventory-1", status: "ACTIVE" },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.void({
|
||||
saleId: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
actorUserId: 'user-1',
|
||||
reason: ' Duplicate sale ',
|
||||
saleId: "sale-1",
|
||||
sellerId: "seller-1",
|
||||
actorUserId: "user-1",
|
||||
reason: " Duplicate sale ",
|
||||
});
|
||||
|
||||
assert.equal(received.reason, 'Duplicate sale');
|
||||
assert.equal(result.inventoryItem.status, 'ACTIVE');
|
||||
assert.equal(received.reason, "Duplicate sale");
|
||||
assert.equal(result.inventoryItem.status, "ACTIVE");
|
||||
await assert.rejects(
|
||||
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }),
|
||||
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400,
|
||||
() =>
|
||||
service.void({
|
||||
saleId: "sale-1",
|
||||
sellerId: "seller-1",
|
||||
actorUserId: "user-1",
|
||||
reason: "x",
|
||||
}),
|
||||
(error) =>
|
||||
error.code === "SALE_VOID_REASON_REQUIRED" && error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates one sale containing multiple inventory items', async () => {
|
||||
test("creates one sale containing multiple inventory items", async () => {
|
||||
let received;
|
||||
const service = createSaleService({
|
||||
saleRepository: {
|
||||
async createForInventoryItems(input) {
|
||||
received = input;
|
||||
return {
|
||||
status: 'CREATED',
|
||||
sale: { id: 'sale-2', soldPriceMinor: 3000 },
|
||||
status: "CREATED",
|
||||
sale: { id: "sale-2", soldPriceMinor: 3000 },
|
||||
saleItems: input.items,
|
||||
saleItem: input.items[0],
|
||||
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })),
|
||||
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' },
|
||||
inventoryItems: input.items.map(({ inventoryItemId }) => ({
|
||||
id: inventoryItemId,
|
||||
status: "SOLD",
|
||||
})),
|
||||
inventoryItem: { id: input.items[0].inventoryItemId, status: "SOLD" },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
sellerId: "seller-1",
|
||||
saleDate: "2026-09-09",
|
||||
items: [
|
||||
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 },
|
||||
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 },
|
||||
{
|
||||
inventoryItemId: "inventory-1",
|
||||
soldPriceMinor: 1200,
|
||||
costBasisMinor: 500,
|
||||
},
|
||||
{
|
||||
inventoryItemId: "inventory-2",
|
||||
soldPriceMinor: 1800,
|
||||
costBasisMinor: 700,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -139,18 +159,27 @@ test('creates one sale containing multiple inventory items', async () => {
|
||||
assert.equal(result.profitMinor, 1800);
|
||||
});
|
||||
|
||||
test('maps missing and already-sold inventory to application errors', async () => {
|
||||
test("maps missing and already-sold inventory to application errors", async () => {
|
||||
for (const result of [
|
||||
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 },
|
||||
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 },
|
||||
{ status: "NOT_FOUND", code: "INVENTORY_NOT_FOUND", statusCode: 404 },
|
||||
{
|
||||
status: "NOT_ACTIVE",
|
||||
code: "INVENTORY_NOT_AVAILABLE_FOR_SALE",
|
||||
statusCode: 409,
|
||||
},
|
||||
]) {
|
||||
const service = createSaleService({
|
||||
saleRepository: { async createForInventoryItem() { return result; } },
|
||||
saleRepository: {
|
||||
async createForInventoryItem() {
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.create(validInput()),
|
||||
(error) => error.code === result.code && error.statusCode === result.statusCode,
|
||||
(error) =>
|
||||
error.code === result.code && error.statusCode === result.statusCode,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
calculateAcquisitionCostBasis,
|
||||
createAcquisition,
|
||||
} from '../../src/modules/acquisitions/domain/acquisition.js';
|
||||
} from "../../src/modules/acquisitions/domain/acquisition.js";
|
||||
import {
|
||||
calculateMedianPriceMinor,
|
||||
createPricingObservation,
|
||||
identifyOutlierPrices,
|
||||
} from '../../src/modules/pricing/domain/pricing-observation.js';
|
||||
} from "../../src/modules/pricing/domain/pricing-observation.js";
|
||||
|
||||
test('calculates acquisition cost basis from all cost components', () => {
|
||||
test("calculates acquisition cost basis from all cost components", () => {
|
||||
const total = calculateAcquisitionCostBasis({
|
||||
costMinor: 1000,
|
||||
shippingMinor: 150,
|
||||
@@ -23,43 +23,48 @@ test('calculates acquisition cost basis from all cost components', () => {
|
||||
assert.equal(total, 1075);
|
||||
});
|
||||
|
||||
test('rejects negative acquisition values and missing identity fields', () => {
|
||||
test("rejects negative acquisition values and missing identity fields", () => {
|
||||
assert.throws(
|
||||
() => createAcquisition({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
method: 'PURCHASE',
|
||||
() =>
|
||||
createAcquisition({
|
||||
inventoryItemId: "inventory-item-id",
|
||||
method: "PURCHASE",
|
||||
costMinor: -1,
|
||||
}),
|
||||
/must be a non-negative integer minor unit/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createAcquisition({
|
||||
inventoryItemId: '',
|
||||
method: 'PURCHASE',
|
||||
() =>
|
||||
createAcquisition({
|
||||
inventoryItemId: "",
|
||||
method: "PURCHASE",
|
||||
costMinor: 1000,
|
||||
}),
|
||||
/Inventory item ID is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates pricing observations and calculates a median observed price', () => {
|
||||
test("creates pricing observations and calculates a median observed price", () => {
|
||||
const observation = createPricingObservation({
|
||||
productId: 'product-id',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
providerId: 'ebay',
|
||||
productId: "product-id",
|
||||
inventoryItemId: "inventory-item-id",
|
||||
providerId: "ebay",
|
||||
priceMinor: 2499,
|
||||
currency: 'USD',
|
||||
sourceIdentifier: 'listing-1',
|
||||
conditionId: 'condition-id',
|
||||
currency: "USD",
|
||||
sourceIdentifier: "listing-1",
|
||||
conditionId: "condition-id",
|
||||
});
|
||||
|
||||
assert.equal(observation.currency, 'USD');
|
||||
assert.equal(observation.currency, "USD");
|
||||
assert.equal(observation.priceMinor, 2499);
|
||||
assert.equal(calculateMedianPriceMinor([1199, 2499, 2000]), 2000);
|
||||
});
|
||||
|
||||
test('identifies extreme pricing observations without discarding normal values', () => {
|
||||
assert.deepEqual([...identifyOutlierPrices([1000, 1050, 1100, 5000])], [5000]);
|
||||
test("identifies extreme pricing observations without discarding normal values", () => {
|
||||
assert.deepEqual(
|
||||
[...identifyOutlierPrices([1000, 1050, 1100, 5000])],
|
||||
[5000],
|
||||
);
|
||||
assert.equal(identifyOutlierPrices([1000, 1050, 1100]).size, 0);
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -14,6 +14,7 @@ test('creates a product with required product identity fields', () => {
|
||||
name: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
tags: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,54 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
calculateNetSaleProceeds,
|
||||
createSale,
|
||||
createSaleItem,
|
||||
} from '../../src/modules/sales/domain/sale.js';
|
||||
} from "../../src/modules/sales/domain/sale.js";
|
||||
|
||||
test('creates a valid sale record and calculates net proceeds after fees', () => {
|
||||
test("creates a valid sale record and calculates net proceeds after fees", () => {
|
||||
const sale = createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-09-08',
|
||||
saleLocation: 'eBay',
|
||||
sellerId: "seller-id",
|
||||
saleDate: "2026-09-08",
|
||||
saleLocation: "eBay",
|
||||
soldPriceMinor: 4999,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
platformFeesMinor: 300,
|
||||
paymentProcessingFeesMinor: 120,
|
||||
shippingCostMinor: 50,
|
||||
taxesMinor: 90,
|
||||
otherFeesMinor: 40,
|
||||
buyerName: 'Alex',
|
||||
buyerContact: 'alex@example.com',
|
||||
buyerName: "Alex",
|
||||
buyerContact: "alex@example.com",
|
||||
});
|
||||
|
||||
assert.equal(sale.soldPriceMinor, 4999);
|
||||
assert.equal(sale.currency, 'USD');
|
||||
assert.equal(calculateNetSaleProceeds({
|
||||
assert.equal(sale.currency, "USD");
|
||||
assert.equal(
|
||||
calculateNetSaleProceeds({
|
||||
soldPriceMinor: 4999,
|
||||
platformFeesMinor: 300,
|
||||
paymentProcessingFeesMinor: 120,
|
||||
shippingCostMinor: 50,
|
||||
taxesMinor: 90,
|
||||
otherFeesMinor: 40,
|
||||
}), 4399);
|
||||
}),
|
||||
4399,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid sales and negative fee values', () => {
|
||||
test("rejects invalid sales and negative fee values", () => {
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-09-08',
|
||||
() =>
|
||||
createSale({
|
||||
sellerId: "seller-id",
|
||||
saleDate: "2026-09-08",
|
||||
soldPriceMinor: 0,
|
||||
}),
|
||||
/must be a positive integer minor unit/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSaleItem({
|
||||
saleId: 'sale-id',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
() =>
|
||||
createSaleItem({
|
||||
saleId: "sale-id",
|
||||
inventoryItemId: "inventory-item-id",
|
||||
soldPriceMinor: 2000,
|
||||
costBasisMinor: -1,
|
||||
}),
|
||||
@@ -56,20 +61,22 @@ test('rejects invalid sales and negative fee values', () => {
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-02-30',
|
||||
() =>
|
||||
createSale({
|
||||
sellerId: "seller-id",
|
||||
saleDate: "2026-02-30",
|
||||
soldPriceMinor: 2000,
|
||||
}),
|
||||
/must be a valid ISO date/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => createSale({
|
||||
sellerId: 'seller-id',
|
||||
saleDate: '2026-09-08',
|
||||
() =>
|
||||
createSale({
|
||||
sellerId: "seller-id",
|
||||
saleDate: "2026-09-08",
|
||||
soldPriceMinor: 2000,
|
||||
currency: 'US dollars',
|
||||
currency: "US dollars",
|
||||
}),
|
||||
/must be a three-letter ISO currency code/,
|
||||
);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildSearchFilters,
|
||||
createSearchQuery,
|
||||
normalizeSearchQuery,
|
||||
} from '../../src/modules/search/domain/search-query.js';
|
||||
} from "../../src/modules/search/domain/search-query.js";
|
||||
|
||||
test('normalizes a public search query and keeps filter values within bounds', () => {
|
||||
test("normalizes a public search query and keeps filter values within bounds", () => {
|
||||
const query = createSearchQuery({
|
||||
query: ' Charizard ex ',
|
||||
category: 'Cards',
|
||||
query: " Charizard ex ",
|
||||
category: "Cards",
|
||||
setName: "SV151",
|
||||
eventName: "Worlds",
|
||||
tag: "Binder",
|
||||
minPriceMinor: 1000,
|
||||
maxPriceMinor: 5000,
|
||||
page: 1,
|
||||
@@ -18,8 +21,11 @@ test('normalizes a public search query and keeps filter values within bounds', (
|
||||
includeSold: false,
|
||||
});
|
||||
|
||||
assert.equal(query.text, 'charizard ex');
|
||||
assert.equal(query.category, 'Cards');
|
||||
assert.equal(query.text, "charizard ex");
|
||||
assert.equal(query.category, "Cards");
|
||||
assert.equal(query.setName, "SV151");
|
||||
assert.equal(query.eventName, "Worlds");
|
||||
assert.equal(query.tag, "binder");
|
||||
assert.equal(query.minPriceMinor, 1000);
|
||||
assert.equal(query.maxPriceMinor, 5000);
|
||||
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);
|
||||
});
|
||||
|
||||
test('rejects invalid pagination and price boundaries', () => {
|
||||
assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/);
|
||||
assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/);
|
||||
assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/);
|
||||
test("rejects invalid pagination and price boundaries", () => {
|
||||
assert.throws(
|
||||
() => createSearchQuery({ query: "abc", page: 0, limit: 20 }),
|
||||
/page must be >= 1/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createSearchQuery({
|
||||
query: "abc",
|
||||
minPriceMinor: 5000,
|
||||
maxPriceMinor: 1000,
|
||||
}),
|
||||
/minPriceMinor must be <= maxPriceMinor/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSearchQuery({ query: "abc", limit: 0 }),
|
||||
/limit must be between 1 and 100/,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a public query filter set that hides sold items by default', () => {
|
||||
test("builds a public query filter set that hides sold items by default", () => {
|
||||
const filters = buildSearchFilters({ includeSold: false });
|
||||
|
||||
assert.equal(filters.status, 'AVAILABLE');
|
||||
assert.equal(filters.status, "AVAILABLE");
|
||||
assert.equal(filters.includeSold, false);
|
||||
assert.deepEqual(filters.sort, ['recently_added']);
|
||||
assert.deepEqual(filters.sort, ["recently_added"]);
|
||||
});
|
||||
|
||||
test('accepts only the supported public search sort values', () => {
|
||||
assert.equal(createSearchQuery({ sort: 'price_low' }).sort, 'price_low');
|
||||
assert.throws(() => createSearchQuery({ sort: 'random' }), /sort must be recent/);
|
||||
test("accepts only the supported public search sort values", () => {
|
||||
assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
|
||||
assert.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', () => {
|
||||
assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo');
|
||||
test("accepts public status and newly-added filters", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
return buildApp({
|
||||
@@ -9,7 +9,7 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
authorizeUnsafeRequest,
|
||||
acquisitionService: {
|
||||
async create(input) {
|
||||
return { id: 'acquisition-1', ...input, costBasisMinor: 1075 };
|
||||
return { id: "acquisition-1", ...input, costBasisMinor: 1075 };
|
||||
},
|
||||
async list() {
|
||||
return [];
|
||||
@@ -18,21 +18,23 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
test('creates an acquisition for the authenticated seller', async (testContext) => {
|
||||
test("creates an acquisition for the authenticated seller", async (testContext) => {
|
||||
let csrfChecked = false;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecked = true; },
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
csrfChecked = true;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/acquisitions',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/acquisitions",
|
||||
headers: { "x-csrf-token": "csrf-token" },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
method: 'PURCHASE',
|
||||
inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||
method: "PURCHASE",
|
||||
costMinor: 1000,
|
||||
},
|
||||
});
|
||||
@@ -42,16 +44,18 @@ test('creates an acquisition for the authenticated seller', async (testContext)
|
||||
assert.equal(csrfChecked, true);
|
||||
});
|
||||
|
||||
test('rejects invalid acquisition requests', async (testContext) => {
|
||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
||||
test("rejects invalid acquisition requests", async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/acquisitions',
|
||||
payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/acquisitions",
|
||||
payload: { inventoryItemId: "not-a-uuid", method: "", costMinor: -1 },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
||||
assert.equal(response.json().error.code, "VALIDATION_ERROR");
|
||||
});
|
||||
@@ -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 { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingService } = {}) {
|
||||
const products = [];
|
||||
const inventoryItems = [];
|
||||
const productRepository = {
|
||||
async create(product) {
|
||||
const createdProduct = {
|
||||
@@ -49,15 +50,30 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
},
|
||||
inventoryItemRepository: {
|
||||
async create(inventoryItem) {
|
||||
return {
|
||||
id: 'inventory-1',
|
||||
const createdItem = {
|
||||
id: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
|
||||
...inventoryItem,
|
||||
status: 'ACTIVE',
|
||||
createdAt: 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 }),
|
||||
});
|
||||
@@ -146,6 +162,133 @@ test('creates inventory only for the authenticated sellers product', async (test
|
||||
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) => {
|
||||
const app = createTestApp({
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,16 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
test('returns a seller-scoped dashboard', async (testContext) => {
|
||||
test("returns a seller-scoped dashboard", async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } },
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
dashboardService: {
|
||||
async get({ sellerId }) {
|
||||
assert.equal(sellerId, "seller-1");
|
||||
return {
|
||||
counts: {},
|
||||
financials: {},
|
||||
recentSales: [],
|
||||
recentAcquisitions: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/admin/dashboard",
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] });
|
||||
assert.deepEqual(response.json().data, {
|
||||
counts: {},
|
||||
financials: {},
|
||||
recentSales: [],
|
||||
recentAcquisitions: [],
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,25 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
test('accepts a protected pricing observation and valuation request', async (testContext) => {
|
||||
test("accepts a protected pricing observation and valuation request", async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
csrfChecks += 1;
|
||||
},
|
||||
pricingService: {
|
||||
async addObservation(input) { return { id: 'observation-1', ...input }; },
|
||||
async calculate() { return { valuation: { id: 'valuation-1', inventoryItemId: 'inventory-1', valueMinor: 2500, currency: 'USD', strategy: 'MEDIAN', confidence: 'LOW', sourceCount: 1 }, observations: [] }; },
|
||||
async addObservation(input) {
|
||||
return { id: "observation-1", ...input };
|
||||
},
|
||||
async calculate() {
|
||||
return {
|
||||
valuation: {
|
||||
id: "valuation-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
valueMinor: 2500,
|
||||
currency: "USD",
|
||||
strategy: "MEDIAN",
|
||||
confidence: "LOW",
|
||||
sourceCount: 1,
|
||||
},
|
||||
observations: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const observationResponse = await app.inject({
|
||||
method: 'POST', url: '/api/v1/admin/pricing/observations', headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { productId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', inventoryItemId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e', providerId: 'manual', priceMinor: 2500, sourceIdentifier: 'source-1', conditionId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/pricing/observations",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
payload: {
|
||||
productId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||
inventoryItemId: "21eb218b-7a95-4f80-a9e3-e306f7ca1f2e",
|
||||
providerId: "manual",
|
||||
priceMinor: 2500,
|
||||
sourceIdentifier: "source-1",
|
||||
conditionId: "ab8150c7-96c1-4b55-8d69-7d0b77d48958",
|
||||
},
|
||||
});
|
||||
const valuationResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation",
|
||||
});
|
||||
const valuationResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation' });
|
||||
|
||||
assert.equal(observationResponse.statusCode, 201);
|
||||
assert.equal(valuationResponse.statusCode, 200);
|
||||
@@ -27,59 +56,74 @@ test('accepts a protected pricing observation and valuation request', async (tes
|
||||
assert.equal(csrfChecks, 2);
|
||||
});
|
||||
|
||||
test('accepts a CSRF-protected market-value override', async (testContext) => {
|
||||
test("accepts a CSRF-protected market-value override", async (testContext) => {
|
||||
let received;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
pricingService: { async override(input) { received = input; return { id: 'override-1', ...input }; } },
|
||||
pricingService: {
|
||||
async override(input) {
|
||||
received = input;
|
||||
return { id: "override-1", ...input };
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override',
|
||||
headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { valueMinor: 2600, reason: 'Verified recent sale' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/inventory/21eb218b-7a95-4f80-a9e3-e306f7ca1f2e/valuation/override",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
payload: { valueMinor: 2600, reason: "Verified recent sale" },
|
||||
});
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(received.actorUserId, 'user-1');
|
||||
assert.equal(received.actorUserId, "user-1");
|
||||
assert.equal(response.json().data.valueMinor, 2600);
|
||||
});
|
||||
|
||||
test('enqueues a pricing refresh and exposes seller-scoped job status', async (testContext) => {
|
||||
test("enqueues a pricing refresh and exposes seller-scoped job status", async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
pricingService: {
|
||||
async enqueueRefresh(input) { return { id: 'job-1', status: 'PENDING', createdAt: input.sellerId }; },
|
||||
async getJob(input) { assert.equal(input.sellerId, 'seller-1'); return { id: input.jobId, status: 'COMPLETED', error: null }; },
|
||||
async enqueueRefresh(input) {
|
||||
return { id: "job-1", status: "PENDING", createdAt: input.sellerId };
|
||||
},
|
||||
async getJob(input) {
|
||||
assert.equal(input.sellerId, "seller-1");
|
||||
return { id: input.jobId, status: "COMPLETED", error: null };
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/inventory-1/pricing/refresh',
|
||||
headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { providerId: 'missing' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/inventory/inventory-1/pricing/refresh",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
payload: { providerId: "missing" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.json().data.status, 'PENDING');
|
||||
assert.equal(response.json().data.status, "PENDING");
|
||||
|
||||
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' });
|
||||
const jobResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/admin/jobs/job-1",
|
||||
});
|
||||
assert.equal(jobResponse.statusCode, 200);
|
||||
assert.equal(jobResponse.json().data.status, 'COMPLETED');
|
||||
assert.equal(jobResponse.json().data.status, "COMPLETED");
|
||||
});
|
||||
|
||||
test('returns a conflict when refresh has no active product mapping', async (testContext) => {
|
||||
test("returns a conflict when refresh has no active product mapping", async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
pricingService: {
|
||||
async enqueueRefresh() {
|
||||
const error = new Error('No active pricing provider mapping exists for this product.');
|
||||
error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND';
|
||||
const error = new Error(
|
||||
"No active pricing provider mapping exists for this product.",
|
||||
);
|
||||
error.code = "PRICING_PROVIDER_MAPPING_NOT_FOUND";
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
},
|
||||
@@ -88,30 +132,88 @@ test('returns a conflict when refresh has no active product mapping', async (tes
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/item-1/pricing/refresh',
|
||||
headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { productId: 'product-1', conditionId: 'condition-1' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/inventory/item-1/pricing/refresh",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
payload: { productId: "product-1", conditionId: "condition-1" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 409);
|
||||
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND');
|
||||
assert.equal(
|
||||
response.json().error.code,
|
||||
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
|
||||
);
|
||||
});
|
||||
|
||||
test('lists jobs and retries a failed job with CSRF protection', async (testContext) => {
|
||||
test("lists jobs and retries a failed job with CSRF protection", async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
csrfChecks += 1;
|
||||
},
|
||||
pricingService: {
|
||||
async listJobs() { return [{ id: 'job-1', status: 'FAILED', providerId: 'fixture', inventoryItemId: 'item-1' }]; },
|
||||
async retryJob() { return { id: 'job-1', status: 'PENDING' }; },
|
||||
async listJobs() {
|
||||
return [
|
||||
{
|
||||
id: "job-1",
|
||||
status: "FAILED",
|
||||
providerId: "fixture",
|
||||
inventoryItemId: "item-1",
|
||||
},
|
||||
];
|
||||
},
|
||||
async retryJob() {
|
||||
return { id: "job-1", status: "PENDING" };
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs' });
|
||||
const retryResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/jobs/job-1/retry', headers: { 'x-csrf-token': 'csrf' } });
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/admin/jobs",
|
||||
});
|
||||
const retryResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/jobs/job-1/retry",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
});
|
||||
assert.equal(listResponse.statusCode, 200);
|
||||
assert.equal(retryResponse.statusCode, 202);
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
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 test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
test('protects provider mapping writes and scopes mappings to the seller', async (testContext) => {
|
||||
test("protects provider mapping writes and scopes mappings to the seller", async (testContext) => {
|
||||
let received;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
providerMappingService: {
|
||||
async create(input) { received = input; return { id: 'mapping-1', ...input, status: 'ACTIVE' }; },
|
||||
async list() { return []; },
|
||||
async disable() { return { id: 'mapping-1', status: 'DISABLED' }; },
|
||||
async create(input) {
|
||||
received = input;
|
||||
return { id: "mapping-1", ...input, status: "ACTIVE" };
|
||||
},
|
||||
async list() {
|
||||
return [];
|
||||
},
|
||||
async disable() {
|
||||
return { id: "mapping-1", status: "DISABLED" };
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products/product-1/provider-mappings',
|
||||
headers: { 'x-csrf-token': 'csrf' },
|
||||
payload: { providerId: 'fixture', mappingType: 'PRODUCT', externalIdentifier: 'ext-1' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/products/product-1/provider-mappings",
|
||||
headers: { "x-csrf-token": "csrf" },
|
||||
payload: {
|
||||
providerId: "fixture",
|
||||
mappingType: "PRODUCT",
|
||||
externalIdentifier: "ext-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(received.sellerId, 'seller-1');
|
||||
assert.equal(response.json().data.providerId, 'fixture');
|
||||
assert.equal(received.sellerId, "seller-1");
|
||||
assert.equal(response.json().data.providerId, "fixture");
|
||||
});
|
||||
|
||||
@@ -36,6 +36,10 @@ function createTestApp() {
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
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('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.json().error.code, 'LISTING_NOT_FOUND');
|
||||
});
|
||||
@@ -21,6 +21,10 @@ function createTestApp() {
|
||||
name: 'Pikachu Plush',
|
||||
slug,
|
||||
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'),
|
||||
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,
|
||||
eventName: 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',
|
||||
updatedAt: '2026-09-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
test('returns public search results with pagination metadata', async (testContext) => {
|
||||
test("returns public search results with pagination metadata", async (testContext) => {
|
||||
const app = buildApp({
|
||||
searchService: {
|
||||
async searchPublic(filters) {
|
||||
assert.equal(filters.page, 2);
|
||||
assert.equal(filters.limit, 5);
|
||||
assert.equal(filters.includeSold, true);
|
||||
assert.equal(filters.sort, 'price_low');
|
||||
assert.equal(filters.conditionId, 'condition-1');
|
||||
assert.equal(filters.sort, "price_low");
|
||||
assert.equal(filters.conditionId, "condition-1");
|
||||
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 {
|
||||
items: [{ id: 'listing-1', status: 'CLOSED' }],
|
||||
items: [{ id: "listing-1", status: "CLOSED" }],
|
||||
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());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1',
|
||||
method: "GET",
|
||||
url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1&setName=SV151&eventName=Worlds&tag=binder&status=SOLD&newlyAdded=true",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
@@ -33,44 +38,71 @@ test('returns public search results with pagination metadata', async (testContex
|
||||
total: 6,
|
||||
totalPages: 2,
|
||||
});
|
||||
assert.equal(response.json().data.items[0].id, 'listing-1');
|
||||
assert.equal(response.json().data.items[0].id, "listing-1");
|
||||
});
|
||||
|
||||
test('rejects malformed numeric search filters', async (testContext) => {
|
||||
const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } });
|
||||
test("rejects malformed numeric search filters", async (testContext) => {
|
||||
const app = buildApp({
|
||||
searchService: {
|
||||
async searchPublic() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/public/search?minPriceMinor=12.5',
|
||||
method: "GET",
|
||||
url: "/api/v1/public/search?minPriceMinor=12.5",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER');
|
||||
assert.equal(response.json().error.code, "INVALID_SEARCH_FILTER");
|
||||
});
|
||||
|
||||
test('returns active public category options', async (testContext) => {
|
||||
test("returns active public category options", async (testContext) => {
|
||||
const app = buildApp({
|
||||
catalogLookupRepository: {
|
||||
async listCategories() { return [{ id: 'category-1', name: 'Trading Cards' }]; },
|
||||
async listCategories() {
|
||||
return [{ id: "category-1", name: "Trading Cards" }];
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/public/lookups/categories",
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]);
|
||||
assert.deepEqual(response.json().data, [
|
||||
{ id: "category-1", name: "Trading Cards" },
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns public condition options without category internals', async (testContext) => {
|
||||
test("returns public condition options without category internals", async (testContext) => {
|
||||
const app = buildApp({
|
||||
catalogLookupRepository: {
|
||||
async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; },
|
||||
async listConditions() {
|
||||
return [
|
||||
{
|
||||
id: "condition-1",
|
||||
name: "Near Mint",
|
||||
code: "NM",
|
||||
categoryId: "private-category",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/public/lookups/conditions",
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]);
|
||||
assert.deepEqual(response.json().data, [
|
||||
{ id: "condition-1", name: "Near Mint", code: "NM" },
|
||||
]);
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { buildApp } from "../../src/app/build-app.js";
|
||||
|
||||
const saleResult = {
|
||||
sale: {
|
||||
id: 'sale-1',
|
||||
sellerId: 'seller-1',
|
||||
saleDate: '2026-09-09',
|
||||
id: "sale-1",
|
||||
sellerId: "seller-1",
|
||||
saleDate: "2026-09-09",
|
||||
soldPriceMinor: 2499,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
platformFeesMinor: 0,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
@@ -17,12 +17,12 @@ const saleResult = {
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
saleItem: {
|
||||
id: 'sale-item-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
id: "sale-item-1",
|
||||
inventoryItemId: "inventory-1",
|
||||
soldPriceMinor: 2499,
|
||||
costBasisMinor: 1200,
|
||||
},
|
||||
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||
inventoryItem: { id: "inventory-1", status: "SOLD" },
|
||||
};
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
@@ -31,70 +31,74 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
authorizeUnsafeRequest,
|
||||
saleService: {
|
||||
async create(input) {
|
||||
assert.equal(input.sellerId, 'seller-1');
|
||||
assert.equal(input.sellerId, "seller-1");
|
||||
return saleResult;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => {
|
||||
test("creates a sale for the authenticated seller with CSRF protection", async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
csrfChecks += 1;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/sales",
|
||||
headers: { "x-csrf-token": "csrf-token" },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
inventoryItemId: "d2f67d74-9647-48d5-b3a4-74d3b16ea9f8",
|
||||
costBasisMinor: 1200,
|
||||
saleDate: '2026-09-09',
|
||||
saleDate: "2026-09-09",
|
||||
soldPriceMinor: 2499,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
assert.equal(response.json().data.inventoryItem.status, "SOLD");
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => {
|
||||
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
||||
test("rejects invalid sale requests and unauthenticated requests", async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const invalidResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/sales",
|
||||
payload: {
|
||||
inventoryItemId: 'not-a-uuid',
|
||||
inventoryItemId: "not-a-uuid",
|
||||
costBasisMinor: -1,
|
||||
saleDate: 'tomorrow',
|
||||
saleDate: "tomorrow",
|
||||
soldPriceMinor: 0,
|
||||
},
|
||||
});
|
||||
assert.equal(invalidResponse.statusCode, 400);
|
||||
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR');
|
||||
assert.equal(invalidResponse.json().error.code, "VALIDATION_ERROR");
|
||||
|
||||
const unauthenticatedApp = createTestApp();
|
||||
testContext.after(() => unauthenticatedApp.close());
|
||||
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales',
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/sales",
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(unauthenticatedResponse.statusCode, 401);
|
||||
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED');
|
||||
assert.equal(unauthenticatedResponse.json().error.code, "UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test('voids a sale with CSRF protection and actor identity', async (testContext) => {
|
||||
test("voids a sale with CSRF protection and actor identity", async (testContext) => {
|
||||
let voidInput;
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||
authenticateAdmin: async () => ({ userId: "user-1", sellerId: "seller-1" }),
|
||||
authorizeUnsafeRequest: async () => {},
|
||||
saleService: {
|
||||
async void(input) {
|
||||
@@ -106,13 +110,13 @@ test('voids a sale with CSRF protection and actor identity', async (testContext)
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/sales/sale-1/void',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
payload: { reason: 'Duplicate entry' },
|
||||
method: "POST",
|
||||
url: "/api/v1/admin/sales/sale-1/void",
|
||||
headers: { "x-csrf-token": "csrf-token" },
|
||||
payload: { reason: "Duplicate entry" },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(voidInput.actorUserId, 'user-1');
|
||||
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||
assert.equal(voidInput.actorUserId, "user-1");
|
||||
assert.equal(response.json().data.inventoryItem.status, "SOLD");
|
||||
});
|
||||
@@ -1,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 test, { after, beforeEach } from 'node:test';
|
||||
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
products,
|
||||
securityAuditEvents,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
@@ -190,6 +193,23 @@ test('finds only products with active inventory and a public listing', async ()
|
||||
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
|
||||
.update(listings)
|
||||
.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 test, { after, beforeEach } from 'node:test';
|
||||
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.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,
|
||||
);
|
||||
|
||||
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
|
||||
.update(listings)
|
||||
.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 () => {
|
||||
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');
|
||||
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 test, { after, beforeEach } from 'node:test';
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after, beforeEach } from "node:test";
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
||||
import { createDatabase } from "../../src/infrastructure/database/client.js";
|
||||
import {
|
||||
categories,
|
||||
conditionProfiles,
|
||||
@@ -13,76 +13,104 @@ import {
|
||||
securityAuditEvents,
|
||||
sellers,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createAcquisitionRepository } from '../../src/modules/acquisitions/repositories/acquisition-repository.js';
|
||||
import { createPricingRepository } from '../../src/modules/pricing/repositories/pricing-repository.js';
|
||||
import { createPricingRefreshJobRepository } from '../../src/modules/pricing/repositories/pricing-refresh-job-repository.js';
|
||||
import { createProviderMappingRepository } from '../../src/modules/pricing/repositories/provider-mapping-repository.js';
|
||||
import { createSaleRepository } from '../../src/modules/sales/repositories/sale-repository.js';
|
||||
} from "../../src/infrastructure/database/schema.js";
|
||||
import { createAcquisitionRepository } from "../../src/modules/acquisitions/repositories/acquisition-repository.js";
|
||||
import { createPricingRepository } from "../../src/modules/pricing/repositories/pricing-repository.js";
|
||||
import { createPricingRefreshJobRepository } from "../../src/modules/pricing/repositories/pricing-refresh-job-repository.js";
|
||||
import { createProviderMappingRepository } from "../../src/modules/pricing/repositories/provider-mapping-repository.js";
|
||||
import { createSaleRepository } from "../../src/modules/sales/repositories/sale-repository.js";
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
||||
throw new Error("TEST_DATABASE_URL is required to run integration tests.");
|
||||
}
|
||||
|
||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
||||
if (!new URL(connectionString).pathname.endsWith("_test")) {
|
||||
throw new Error(
|
||||
'TEST_DATABASE_URL must reference a database whose name ends in "_test".',
|
||||
);
|
||||
}
|
||||
|
||||
const database = createDatabase(connectionString);
|
||||
const acquisitionRepository = createAcquisitionRepository(database.db);
|
||||
const pricingRepository = createPricingRepository(database.db);
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(database.db);
|
||||
const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||
database.db,
|
||||
);
|
||||
const providerMappingRepository = createProviderMappingRepository(database.db);
|
||||
const saleRepository = createSaleRepository(database.db);
|
||||
|
||||
async function createFixture() {
|
||||
const [user] = await database.db.insert(users).values({
|
||||
email: 'transactions@example.test',
|
||||
passwordHash: 'not-a-real-password',
|
||||
}).returning();
|
||||
const [seller] = await database.db.insert(sellers).values({
|
||||
async function createFixture(suffix = "") {
|
||||
const fixtureName = suffix ? `transactions-${suffix}` : "transactions";
|
||||
const [user] = await database.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: `${fixtureName}@example.test`,
|
||||
passwordHash: "not-a-real-password",
|
||||
})
|
||||
.returning();
|
||||
const [seller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
displayName: 'Transactions Owner',
|
||||
}).returning();
|
||||
const [category] = await database.db.insert(categories).values({
|
||||
name: 'Transactions Cards',
|
||||
slug: 'transactions-cards',
|
||||
}).returning();
|
||||
const [profile] = await database.db.insert(conditionProfiles).values({
|
||||
displayName: `${fixtureName} Owner`,
|
||||
})
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: `${fixtureName} Cards`,
|
||||
slug: `${fixtureName}-cards`,
|
||||
})
|
||||
.returning();
|
||||
const [profile] = await database.db
|
||||
.insert(conditionProfiles)
|
||||
.values({
|
||||
categoryId: category.id,
|
||||
name: 'Card',
|
||||
}).returning();
|
||||
const [condition] = await database.db.insert(conditions).values({
|
||||
name: "Card",
|
||||
})
|
||||
.returning();
|
||||
const [condition] = await database.db
|
||||
.insert(conditions)
|
||||
.values({
|
||||
conditionProfileId: profile.id,
|
||||
name: 'Near Mint',
|
||||
code: 'NM',
|
||||
name: "Near Mint",
|
||||
code: "NM",
|
||||
qualityRank: 1,
|
||||
}).returning();
|
||||
const [product] = await database.db.insert(products).values({
|
||||
})
|
||||
.returning();
|
||||
const [product] = await database.db
|
||||
.insert(products)
|
||||
.values({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Integration Card',
|
||||
slug: 'integration-card',
|
||||
}).returning();
|
||||
const inventory = await database.db.insert(inventoryItems).values([
|
||||
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();
|
||||
])
|
||||
.returning();
|
||||
|
||||
return { user, seller, product, condition, inventory };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.db.execute(sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`);
|
||||
await database.db.execute(
|
||||
sql`TRUNCATE pricing_provider_mappings, pricing_refresh_jobs, market_value_overrides, market_valuations, pricing_observations, sale_items, sales, acquisitions, security_audit_events, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally', async () => {
|
||||
test("persists acquisitions, valuations, multi-item sales, voids, and audit events transactionally", async () => {
|
||||
const { user, seller, product, condition, inventory } = await createFixture();
|
||||
const [firstItem, secondItem] = inventory;
|
||||
|
||||
@@ -90,47 +118,50 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
|
||||
sellerId: seller.id,
|
||||
acquisition: {
|
||||
inventoryItemId: firstItem.id,
|
||||
method: 'PURCHASE',
|
||||
method: "PURCHASE",
|
||||
costMinor: 1000,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
shippingMinor: 100,
|
||||
taxMinor: 50,
|
||||
otherFeesMinor: 0,
|
||||
estimatedTradeValueMinor: 0,
|
||||
},
|
||||
});
|
||||
assert.equal(acquisition.status, 'CREATED');
|
||||
assert.equal(acquisition.status, "CREATED");
|
||||
|
||||
for (const [item, price] of [[firstItem, 2500], [secondItem, 3000]]) {
|
||||
for (const [item, price] of [
|
||||
[firstItem, 2500],
|
||||
[secondItem, 3000],
|
||||
]) {
|
||||
const observation = await pricingRepository.addObservationForSeller({
|
||||
sellerId: seller.id,
|
||||
observation: {
|
||||
productId: product.id,
|
||||
inventoryItemId: item.id,
|
||||
providerId: 'integration',
|
||||
providerId: "integration",
|
||||
priceMinor: price,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
sourceIdentifier: `source-${item.id}`,
|
||||
conditionId: condition.id,
|
||||
},
|
||||
});
|
||||
assert.equal(observation.status, 'CREATED');
|
||||
assert.equal(observation.status, "CREATED");
|
||||
}
|
||||
|
||||
const valuation = await pricingRepository.calculateForSeller({
|
||||
sellerId: seller.id,
|
||||
inventoryItemId: firstItem.id,
|
||||
});
|
||||
assert.equal(valuation.status, 'CALCULATED');
|
||||
assert.equal(valuation.status, "CALCULATED");
|
||||
assert.equal(valuation.valuation.valueMinor, 2500);
|
||||
assert.equal(valuation.valuation.sourceCount, 1);
|
||||
|
||||
const sale = await saleRepository.createForInventoryItems({
|
||||
sale: {
|
||||
sellerId: seller.id,
|
||||
saleDate: '2026-09-09',
|
||||
saleDate: "2026-09-09",
|
||||
soldPriceMinor: 5500,
|
||||
currency: 'USD',
|
||||
currency: "USD",
|
||||
platformFeesMinor: 200,
|
||||
paymentProcessingFeesMinor: 0,
|
||||
shippingCostMinor: 0,
|
||||
@@ -138,58 +169,130 @@ test('persists acquisitions, valuations, multi-item sales, voids, and audit even
|
||||
otherFeesMinor: 0,
|
||||
},
|
||||
items: [
|
||||
{ inventoryItemId: firstItem.id, soldPriceMinor: 2500, costBasisMinor: 1150 },
|
||||
{ inventoryItemId: secondItem.id, soldPriceMinor: 3000, costBasisMinor: 900 },
|
||||
{
|
||||
inventoryItemId: firstItem.id,
|
||||
soldPriceMinor: 2500,
|
||||
costBasisMinor: 1150,
|
||||
},
|
||||
{
|
||||
inventoryItemId: secondItem.id,
|
||||
soldPriceMinor: 3000,
|
||||
costBasisMinor: 900,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(sale.status, 'CREATED');
|
||||
assert.equal(sale.status, "CREATED");
|
||||
assert.equal(sale.saleItems.length, 2);
|
||||
assert.deepEqual(sale.inventoryItems.map(({ status }) => status), ['SOLD', 'SOLD']);
|
||||
assert.deepEqual(
|
||||
sale.inventoryItems.map(({ status }) => status),
|
||||
["SOLD", "SOLD"],
|
||||
);
|
||||
|
||||
const voided = await saleRepository.voidForSeller({
|
||||
saleId: sale.sale.id,
|
||||
sellerId: seller.id,
|
||||
actorUserId: user.id,
|
||||
reason: 'Integration correction',
|
||||
reason: "Integration correction",
|
||||
});
|
||||
assert.equal(voided.status, 'VOIDED');
|
||||
assert.equal(voided.sale.status, 'VOIDED');
|
||||
assert.equal(voided.status, "VOIDED");
|
||||
assert.equal(voided.sale.status, "VOIDED");
|
||||
assert.equal(voided.saleItems.length, 2);
|
||||
|
||||
const [auditEvent] = await database.db.select().from(securityAuditEvents).where(sql`event = 'SALE_VOIDED'`);
|
||||
const [auditEvent] = await database.db
|
||||
.select()
|
||||
.from(securityAuditEvents)
|
||||
.where(sql`event = 'SALE_VOIDED'`);
|
||||
assert.equal(auditEvent.userId, user.id);
|
||||
});
|
||||
|
||||
test('claims and completes a durable pricing refresh job', async () => {
|
||||
test("claims and completes a durable pricing refresh job", async () => {
|
||||
const { user, seller, product, condition, inventory } = await createFixture();
|
||||
const [job] = await pricingRefreshJobRepository.create({
|
||||
const [job] = await pricingRefreshJobRepository
|
||||
.create({
|
||||
sellerId: seller.id,
|
||||
inventoryItemId: inventory[0].id,
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
providerId: 'integration',
|
||||
}).then((created) => [created]);
|
||||
providerId: "integration",
|
||||
})
|
||||
.then((created) => [created]);
|
||||
|
||||
const claimed = await pricingRefreshJobRepository.claimNext();
|
||||
assert.equal(claimed.id, job.id);
|
||||
assert.equal(claimed.status, 'PROCESSING');
|
||||
const completed = await pricingRefreshJobRepository.complete(job.id, [{ priceMinor: 2500 }], { valueMinor: 2500 });
|
||||
assert.equal(completed.status, 'COMPLETED');
|
||||
assert.equal(claimed.status, "PROCESSING");
|
||||
const completed = await pricingRefreshJobRepository.complete(
|
||||
job.id,
|
||||
[{ priceMinor: 2500 }],
|
||||
{ valueMinor: 2500 },
|
||||
);
|
||||
assert.equal(completed.status, "COMPLETED");
|
||||
assert.deepEqual(completed.valuation, { valueMinor: 2500 });
|
||||
assert.equal(user.email, 'transactions@example.test');
|
||||
assert.equal(user.email, "transactions@example.test");
|
||||
});
|
||||
|
||||
test('persists and disables seller-owned provider mappings', async () => {
|
||||
const { seller, product } = await createFixture();
|
||||
const created = await providerMappingRepository.createForSeller({
|
||||
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,
|
||||
providerId: 'integration',
|
||||
mappingType: 'PRODUCT',
|
||||
externalIdentifier: 'catalog-1',
|
||||
}, seller.id);
|
||||
assert.equal(created.status, 'CREATED');
|
||||
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 1);
|
||||
const disabled = await providerMappingRepository.disableForSeller(created.mapping.id, seller.id);
|
||||
assert.equal(disabled.status, 'DISABLED');
|
||||
assert.equal((await providerMappingRepository.listForSeller(product.id, seller.id)).length, 0);
|
||||
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 created = await providerMappingRepository.createForSeller(
|
||||
{
|
||||
productId: product.id,
|
||||
providerId: "integration",
|
||||
mappingType: "PRODUCT",
|
||||
externalIdentifier: "catalog-1",
|
||||
},
|
||||
seller.id,
|
||||
);
|
||||
assert.equal(created.status, "CREATED");
|
||||
assert.equal(
|
||||
(await providerMappingRepository.listForSeller(product.id, seller.id))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
const disabled = await providerMappingRepository.disableForSeller(
|
||||
created.mapping.id,
|
||||
seller.id,
|
||||
);
|
||||
assert.equal(disabled.status, "DISABLED");
|
||||
assert.equal(
|
||||
(await providerMappingRepository.listForSeller(product.id, seller.id))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { setup } from '@storybook/vue3';
|
||||
|
||||
import '../src/assets/styles/global.scss';
|
||||
import { storybookRouter } from './router.js';
|
||||
|
||||
setup((app) => {
|
||||
app.use(storybookRouter);
|
||||
});
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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-warning-500: #d88a00;
|
||||
--color-danger-500: #e5484d;
|
||||
--color-danger-solid: #b4232b;
|
||||
--color-info-500: #1683c4;
|
||||
--color-success-text: #08783a;
|
||||
--color-warning-text: #8a5700;
|
||||
@@ -91,6 +92,9 @@
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-success-text: #63d991;
|
||||
--color-warning-text: #ffc766;
|
||||
--color-danger-text: #ff9297;
|
||||
color-scheme: dark;
|
||||
--color-background: #0b0d10;
|
||||
--color-surface: #11161d;
|
||||
|
||||
@@ -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 { cursor: pointer; }
|
||||
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-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; }
|
||||
.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-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 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; }
|
||||
@@ -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 dd { margin: 0; }
|
||||
.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 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; }
|
||||
|
||||
.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-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); }
|
||||
.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-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); }
|
||||
@@ -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-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-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 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; }
|
||||
@@ -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--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--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; }
|
||||
.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); }
|
||||
@@ -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-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
Reference in New Issue
Block a user