Compare commits

...
77 Commits
Author SHA1 Message Date
fragginwagon 3132e80646 fix: label image actions by item
Quality / test (push) Canceled after 0s
2026-09-13 17:55:02 -04:00
fragginwagon a81f882831 test: cover image upload guidance
Quality / test (push) Canceled after 0s
2026-09-13 17:53:31 -04:00
fragginwagon 6840c9e6ba fix: clarify image upload constraints
Quality / test (push) Canceled after 0s
2026-09-13 17:40:40 -04:00
fragginwagon 3dbb2e8001 fix: clarify listing review fields
Quality / test (push) Canceled after 0s
2026-09-13 17:39:11 -04:00
fragginwagon 312b1e5bbc fix: clarify product and copy fields
Quality / test (push) Canceled after 0s
2026-09-11 15:52:30 -04:00
fragginwagon cc45a4f119 fix: clarify mapping and pricing fields
Quality / test (push) Canceled after 0s
2026-09-11 15:41:08 -04:00
fragginwagon 7c6e00fca3 fix: clarify admin transaction amounts
Quality / test (push) Canceled after 0s
2026-09-11 15:40:19 -04:00
fragginwagon 255848fcaa test: cover session expiry messaging
Quality / test (push) Canceled after 0s
2026-09-11 15:31:52 -04:00
fragginwagon f1190fa806 feat: explain expired admin sessions
Quality / test (push) Canceled after 0s
2026-09-11 13:24:27 -04:00
fragginwagon 7786671c00 fix: expose logout progress
Quality / test (push) Canceled after 0s
2026-09-11 12:36:09 -04:00
fragginwagon 0f04ef45fe test: cover login error focus
Quality / test (push) Canceled after 0s
2026-09-11 12:14:11 -04:00
fragginwagon 38406f7c60 fix: focus login errors
Quality / test (push) Canceled after 0s
2026-09-11 11:51:59 -04:00
fragginwagon e3db8df92e fix: improve settings and saved view accessibility
Quality / test (push) Canceled after 0s
2026-09-11 11:28:12 -04:00
fragginwagon fef7c0a339 fix: improve listing status accessibility
Quality / test (push) Canceled after 0s
2026-09-11 10:58:30 -04:00
fragginwagon 4217009384 fix: label admin transaction history
Quality / test (push) Canceled after 0s
2026-09-11 09:21:54 -04:00
fragginwagon bb8499e10c fix: label admin inventory results
Quality / test (push) Canceled after 0s
2026-09-11 09:21:00 -04:00
fragginwagon 18a99595e5 test: cover inventory action announcements
Quality / test (push) Canceled after 0s
2026-09-11 09:20:17 -04:00
fragginwagon 98d2adac17 feat: announce admin inventory actions
Quality / test (push) Canceled after 0s
2026-09-11 09:18:19 -04:00
fragginwagon eb43cef872 feat: add admin navigation skip link
Quality / test (push) Canceled after 0s
2026-09-11 09:17:18 -04:00
fragginwagon f54223966c feat: enforce per-item image count limits
Quality / test (push) Canceled after 0s
2026-09-11 00:39:27 -04:00
fragginwagon e6510c0317 fix: inspect uploaded image dimensions on the server
Quality / test (push) Canceled after 0s
2026-09-11 00:35:07 -04:00
fragginwagon cce395baf6 fix: validate image order and alt text updates
Quality / test (push) Canceled after 0s
2026-09-11 00:18:11 -04:00
fragginwagon 0e81fc4022 feat: reorder inventory images atomically
Quality / test (push) Canceled after 0s
2026-09-11 00:13:44 -04:00
fragginwagon 72e9a79b67 fix: preserve image manager state during action failures
Quality / test (push) Canceled after 0s
2026-09-10 17:57:28 -04:00
fragginwagon c5a07b2b5a fix: recover from saved view load and mutation failures
Quality / test (push) Canceled after 0s
2026-09-10 17:49:39 -04:00
fragginwagon d321d8c679 fix: recover safely from settings load and save failures
Quality / test (push) Canceled after 0s
2026-09-10 17:44:53 -04:00
fragginwagon 9a790ce294 fix: allow sellers to remove public contact methods
Quality / test (push) Canceled after 0s
2026-09-10 07:56:17 -04:00
fragginwagon 73c430b8a5 feat: preserve focus and catalog context during public navigation
Quality / test (push) Canceled after 0s
2026-09-10 07:53:03 -04:00
fragginwagon a02965de4a fix: improve public detail keyboard access and reflow
Quality / test (push) Canceled after 0s
2026-09-10 07:48:42 -04:00
fragginwagon 94c37250d1 feat: configure and display public seller descriptions
Quality / test (push) Canceled after 0s
2026-09-10 07:40:18 -04:00
fragginwagon e630a89e8d feat: surface seller contact methods on public detail pages
Quality / test (push) Canceled after 0s
2026-09-10 07:16:41 -04:00
fragginwagon 54a8d87293 fix: correct interactive text contrast and add detail page stories
Quality / test (push) Canceled after 0s
2026-09-10 07:09:54 -04:00
fragginwagon 6b528350c0 fix: correct catalog error contrast and add catalog Storybook coverage
Quality / test (push) Canceled after 0s
2026-09-10 07:04:40 -04:00
fragginwagon c85693c919 feat: extract dedicated admin inventory management screen
Quality / test (push) Canceled after 0s
2026-09-10 06:46:11 -04:00
fragginwagon 934aad2afd feat: manage focus and announce steps in guided wizard
Quality / test (push) Canceled after 0s
2026-09-10 06:37:02 -04:00
fragginwagon 1fa4bb1c7a feat: extract dedicated admin sales and acquisitions screens
Quality / test (push) Canceled after 0s
2026-09-10 06:32:31 -04:00
fragginwagon 6c99ffaeba feat: extract dedicated admin listings management screen
Quality / test (push) Canceled after 0s
2026-09-10 06:28:35 -04:00
fragginwagon 54be51ed5d feat: build true guided inventory workflow wizard
Quality / test (push) Canceled after 0s
2026-09-10 06:23:51 -04:00
fragginwagon c9f6a58516 feat: rebuild admin image manager per-image controls
Quality / test (push) Canceled after 0s
2026-09-10 06:15:51 -04:00
fragginwagon 7d20aedd14 feat: improve public listing detail states
Quality / test (push) Canceled after 0s
2026-09-10 06:09:13 -04:00
fragginwagon ae1605c82d feat: extract review and list workflow step
Quality / test (push) Canceled after 0s
2026-09-10 06:03:48 -04:00
fragginwagon 82b012faa1 feat: extract pricing review workflow step
Quality / test (push) Canceled after 0s
2026-09-10 06:01:37 -04:00
fragginwagon ff6f3359aa feat: extract inventory images workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:25:33 -04:00
fragginwagon 13702bb3ef feat: extract inventory copy workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:13:17 -04:00
fragginwagon 7ad5635900 feat: extract provider mapping workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:11:19 -04:00
fragginwagon 3e29ca0529 feat: extract product identity workflow step
Quality / test (push) Canceled after 0s
2026-09-10 03:09:39 -04:00
fragginwagon 8ff547feaa feat: add inventory workflow stepper
Quality / test (push) Canceled after 0s
2026-09-10 03:07:51 -04:00
fragginwagon ffb86ce21a feat: improve mobile catalog filters
Quality / test (push) Canceled after 0s
2026-09-10 03:05:49 -04:00
fragginwagon 81705af6e3 feat: add market value catalog sorting
Quality / test (push) Canceled after 0s
2026-09-10 03:04:10 -04:00
fragginwagon c9ad3a11af feat: add catalog status filters
Quality / test (push) Canceled after 0s
2026-09-10 03:02:23 -04:00
fragginwagon 673701fa7a feat: expand catalog filters
Quality / test (push) Canceled after 0s
2026-09-10 03:00:20 -04:00
fragginwagon b036092775 feat: expand catalog sorting
Quality / test (push) Canceled after 0s
2026-09-10 02:39:42 -04:00
fragginwagon 0008af9f2c feat: enforce deployment environment preflight
Quality / test (push) Canceled after 0s
2026-09-10 02:32:43 -04:00
fragginwagon 96d3bf831e feat: add production environment preflight
Quality / test (push) Canceled after 0s
2026-09-10 02:30:45 -04:00
fragginwagon ea6a839b69 docs: refresh implementation status
Quality / test (push) Canceled after 0s
2026-09-10 02:25:06 -04:00
fragginwagon 1e270f6184 ci: cover worker and deployment gates
Quality / test (push) Canceled after 0s
2026-09-10 02:23:22 -04:00
fragginwagon e069dcaa40 fix: wait for healthy deployment services
Quality / test (push) Canceled after 0s
2026-09-10 02:09:25 -04:00
fragginwagon 0cd1b9ff2c docs: sync release readiness status
Quality / test (push) Canceled after 0s
2026-09-10 02:06:25 -04:00
fragginwagon fce30513d4 test: expand authenticated admin workflows
Quality / test (push) Canceled after 0s
2026-09-10 02:01:00 -04:00
fragginwagon 4f0e3d89f6 feat: enforce production pricing provider config
Quality / test (push) Canceled after 0s
2026-09-10 01:46:58 -04:00
fragginwagon f837324cf5 docs: update implementation status
Quality / test (push) Canceled after 0s
2026-09-10 01:42:49 -04:00
fragginwagon ffd239d27c feat: add seller saved views
Quality / test (push) Canceled after 0s
2026-09-10 01:36:15 -04:00
fragginwagon ec322df25c feat: download completed export jobs
Quality / test (push) Canceled after 0s
2026-09-10 01:31:09 -04:00
fragginwagon ad8f675f5e fix: register inventory export jobs schema
Quality / test (push) Canceled after 0s
2026-09-10 01:25:20 -04:00
fragginwagon 7c4885cf65 feat: add asynchronous inventory exports
Quality / test (push) Canceled after 0s
2026-09-10 01:25:11 -04:00
fragginwagon abb0b64217 fix: export settings validation schema
Quality / test (push) Canceled after 0s
2026-09-10 01:15:08 -04:00
fragginwagon cf61ec3c2c feat: add seller settings
Quality / test (push) Canceled after 0s
2026-09-10 01:14:57 -04:00
fragginwagon 24f51212e5 feat: add admin inventory Excel export
Quality / test (push) Canceled after 0s
2026-09-10 01:11:07 -04:00
fragginwagon d6b4dbec5f feat: add Product tags and bulk tag updates
Quality / test (push) Canceled after 0s
2026-09-10 00:51:05 -04:00
fragginwagon 7c9518923d feat: add admin inventory CSV export
Quality / test (push) Canceled after 0s
2026-09-10 00:31:00 -04:00
fragginwagon d3821e9c76 feat: add admin inventory JSON export
Quality / test (push) Canceled after 0s
2026-09-10 00:16:04 -04:00
fragginwagon 099ec2fe72 feat: add bulk pricing refresh
Quality / test (push) Canceled after 0s
2026-09-10 00:07:29 -04:00
fragginwagon e0d231facc feat: expose bulk archive in admin workspace
Quality / test (push) Canceled after 0s
2026-09-09 23:48:34 -04:00
fragginwagon 12eced8b19 feat: add seller-scoped bulk archive
Quality / test (push) Canceled after 0s
Add the first bulk inventory operation through a validated, CSRF-protected API route with lifecycle and ownership enforcement, tests, and implementation-status documentation.
2026-09-09 23:11:48 -04:00
fragginwagon aaa9e2a370 fix: require https for external pricing providers
Quality / test (push) Canceled after 0s
Require HTTPS-only external pricing-provider URLs and verify the adapter contract with the application suite.
2026-09-09 22:42:35 -04:00
fragginwagon 79df9d5c88 feat: add restore drill coverage
Quality / test (push) Canceled after 0s
Add restore verification and image-storage recovery scripts, update deployment documentation, and note the restore drill milestone in the implementation status.
2026-09-09 22:35:25 -04:00
fragginwagon 76e40d7289 feat: schedule pricing job retention cleanup
Quality / test (push) Canceled after 0s
Schedule configurable completed pricing-job cleanup in the worker, broaden repository cleanup across sellers, add unit and integration coverage, and update pricing operational documentation.
2026-09-09 22:32:04 -04:00
184 changed files with 6659 additions and 857 deletions
+2
View File
@@ -9,6 +9,8 @@ PRICING_FIXTURE_PROVIDER_ENABLED=false
PRICING_HTTP_PROVIDER_ID=http-provider PRICING_HTTP_PROVIDER_ID=http-provider
PRICING_HTTP_PROVIDER_ENDPOINT= PRICING_HTTP_PROVIDER_ENDPOINT=
PRICING_HTTP_PROVIDER_TOKEN= PRICING_HTTP_PROVIDER_TOKEN=
PRICING_JOB_RETENTION_DAYS=30
PRICING_JOB_CLEANUP_INTERVAL_MS=3600000
PASSWORD_RESET_URL=http://localhost:5173/reset-password PASSWORD_RESET_URL=http://localhost:5173/reset-password
SMTP_FROM=no-reply@example.test SMTP_FROM=no-reply@example.test
SMTP_URL=smtp://localhost:1025 SMTP_URL=smtp://localhost:1025
+13
View File
@@ -47,6 +47,19 @@ jobs:
DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
- run: npm test - run: npm test
- name: Run worker tests
run: npm run test --workspace=@pokemon-inventory/worker
- name: Audit production dependencies
run: npm audit --omit=dev
- name: Validate deployment scripts
run: |
sh -n scripts/*.sh
sh scripts/deployment-health-wait.test.sh
sh scripts/verify-postgres-backup.test.sh
sh scripts/restore-postgres.test.sh
sh scripts/restore-image-storage.test.sh
sh scripts/smoke-production.test.sh
sh scripts/validate-production-env.test.sh
- name: Run integration tests - name: Run integration tests
env: env:
TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test
+1 -1
View File
@@ -132,7 +132,7 @@ With `npm run dev` running, configured local admin credentials, and demo lookup
npm run test:e2e npm run test:e2e
``` ```
The workflow covers login, admin overview loading, product creation, physical inventory creation, and navigation to image management. It skips when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured. The authenticated workflows cover login, admin overview loading, product and physical inventory creation, image management, listing publication, sales and audited sale correction, settings updates, Saved Views CRUD, and export controls. They skip when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured.
## Initial Administrator ## Initial Administrator
+15
View File
@@ -20,6 +20,9 @@ import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/a
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js"; import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js"; import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js"; import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
import { registerAdminSavedViewRoutes } from "../modules/search/routes/admin-saved-view-routes.js";
export function buildApp(dependencies) { export function buildApp(dependencies) {
const app = Fastify(); const app = Fastify();
@@ -88,6 +91,18 @@ export function buildApp(dependencies) {
...dependencies, ...dependencies,
prefix: "/api/v1/admin", prefix: "/api/v1/admin",
}); });
app.register(registerAdminExportRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerAdminSettingsRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerAdminSavedViewRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerPublicSearchRoutes, { app.register(registerPublicSearchRoutes, {
...dependencies, ...dependencies,
prefix: "/api/v1", prefix: "/api/v1",
+45 -16
View File
@@ -1,3 +1,4 @@
import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -51,7 +52,16 @@ import { createProviderMappingRepository } from "../modules/pricing/repositories
import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js"; import { createProviderMappingService } from "../modules/pricing/services/provider-mapping-service.js";
import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js"; import { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js"; import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js"; import {
assertProductionPricingProviderConfig,
createHttpPricingProvider,
} from "../modules/pricing/providers/http-pricing-provider.js";
import { createAdminExportService } from "../modules/exports/services/admin-export-service.js";
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
import { createSavedViewRepository } from "../modules/search/repositories/saved-view-repository.js";
import { createSavedViewService } from "../modules/search/services/saved-view-service.js";
function parsePort(value) { function parsePort(value) {
const port = Number.parseInt(value ?? "3000", 10); const port = Number.parseInt(value ?? "3000", 10);
@@ -64,6 +74,12 @@ function parsePort(value) {
} }
async function startServer() { async function startServer() {
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( const imageStorageRoot = resolveProjectImageStorageRoot(
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage", process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
import.meta.url, import.meta.url,
@@ -89,6 +105,12 @@ async function startServer() {
const providerMappingRepository = createProviderMappingRepository( const providerMappingRepository = createProviderMappingRepository(
database.db, database.db,
); );
const sellerSettingsService = createSellerSettingsService({
sellerSettingsRepository: createSellerSettingsRepository(database.db),
});
const savedViewService = createSavedViewService({
savedViewRepository: createSavedViewRepository(database.db),
});
const providers = []; const providers = [];
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true") if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
providers.push(createFixturePricingProvider()); providers.push(createFixturePricingProvider());
@@ -101,6 +123,18 @@ async function startServer() {
}), }),
); );
const pricingProviders = createPricingProviderRegistry(providers); const pricingProviders = createPricingProviderRegistry(providers);
const pricingService = createPricingService({
pricingRepository,
pricingRefreshJobRepository,
providerMappingRepository,
pricingProviderRegistry: pricingProviders,
});
const inventoryItemService = createInventoryItemService({
conditionRepository,
inventoryItemRepository,
pricingService,
productRepository,
});
const userRepository = createUserRepository(database.db); const userRepository = createUserRepository(database.db);
const sessionRepository = createSessionRepository(database.db); const sessionRepository = createSessionRepository(database.db);
const passwordResetRepository = createPasswordResetRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db);
@@ -146,11 +180,13 @@ async function startServer() {
keyPrefix: "pokemon-inventory:auth:password-reset:", keyPrefix: "pokemon-inventory:auth:password-reset:",
redisClient: redis.client, redisClient: redis.client,
}), }),
inventoryItemService: createInventoryItemService({ inventoryItemService,
conditionRepository, adminExportService: createAdminExportService({
inventoryItemRepository, inventoryExportJobRepository: createInventoryExportJobRepository(database.db),
productRepository, inventoryItemService,
}), }),
sellerSettingsService,
savedViewService,
catalogLookupRepository, catalogLookupRepository,
publicImageService, publicImageService,
publicInventoryService: createPublicInventoryService({ publicInventoryService: createPublicInventoryService({
@@ -158,9 +194,11 @@ async function startServer() {
publicImageService, publicImageService,
}), }),
inventoryImageService: createInventoryImageService({ inventoryImageService: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: createLocalImageStorage(imageStorageRoot), imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository, imageDerivativeJobRepository,
inventoryImageRepository, inventoryImageRepository,
sellerSettingsRepository,
}), }),
listingService: createListingService({ listingService: createListingService({
listingRepository, listingRepository,
@@ -170,11 +208,7 @@ async function startServer() {
acquisitionService: createAcquisitionService({ acquisitionRepository }), acquisitionService: createAcquisitionService({ acquisitionRepository }),
dashboardService: createDashboardService({ dashboardService: createDashboardService({
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
inventoryItemService: createInventoryItemService({ inventoryItemService,
conditionRepository,
inventoryItemRepository,
productRepository,
}),
listingService: createListingService({ listingService: createListingService({
listingRepository, listingRepository,
inventoryImageRepository, inventoryImageRepository,
@@ -184,12 +218,7 @@ async function startServer() {
pricingRepository, pricingRepository,
pricingRefreshJobRepository, pricingRefreshJobRepository,
}), }),
pricingService: createPricingService({ pricingService,
pricingRepository,
pricingRefreshJobRepository,
providerMappingRepository,
pricingProviderRegistry: pricingProviders,
}),
providerMappingService: createProviderMappingService({ providerMappingService: createProviderMappingService({
providerMappingRepository, providerMappingRepository,
}), }),
@@ -61,6 +61,49 @@ export const sellers = pgTable(
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)], (table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
); );
export const sellerSettings = pgTable("seller_settings", {
sellerId: uuid("seller_id")
.primaryKey()
.references(() => sellers.id, { onDelete: "cascade" }),
settings: jsonb("settings").default({}).notNull(),
...timestamps,
});
export const inventoryExportJobs = pgTable(
"inventory_export_jobs",
{
id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid("seller_id")
.notNull()
.references(() => sellers.id, { onDelete: "cascade" }),
format: text("format").notNull(),
status: text("status").default("PENDING").notNull(),
output: jsonb("output"),
error: text("error"),
...timestamps,
},
(table) => [
index("inventory_export_jobs_status_created_at_index").on(
table.status,
table.createdAt,
),
],
);
export const savedViews = pgTable(
"saved_views",
{
id: uuid("id").defaultRandom().primaryKey(),
sellerId: uuid("seller_id")
.notNull()
.references(() => sellers.id, { onDelete: "cascade" }),
name: text("name").notNull(),
filters: jsonb("filters").default({}).notNull(),
...timestamps,
},
(table) => [uniqueIndex("saved_views_seller_name_unique").on(table.sellerId, table.name)],
);
export const sessions = pgTable( export const sessions = pgTable(
"sessions", "sessions",
{ {
@@ -218,6 +261,7 @@ export const products = pgTable(
cardNumber: text("card_number"), cardNumber: text("card_number"),
eventName: text("event_name"), eventName: text("event_name"),
releaseYear: integer("release_year"), releaseYear: integer("release_year"),
tags: text("tags").array().default([]).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
@@ -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();
},
};
}
@@ -0,0 +1,51 @@
import { and, asc, eq } from 'drizzle-orm';
import { inventoryExportJobs } from '../../../infrastructure/database/schema.js';
export function createInventoryExportJobRepository(db) {
return {
async create(job) {
const [created] = await db.insert(inventoryExportJobs).values(job).returning();
return created;
},
async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(inventoryExportJobs).where(and(
eq(inventoryExportJobs.id, id),
eq(inventoryExportJobs.sellerId, sellerId),
));
return job ?? null;
},
async claimNext() {
return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(inventoryExportJobs)
.where(eq(inventoryExportJobs.status, 'PENDING'))
.orderBy(asc(inventoryExportJobs.createdAt))
.limit(1).for('update', { skipLocked: true });
if (!job) return null;
const [claimed] = await transaction.update(inventoryExportJobs)
.set({ status: 'PROCESSING', updatedAt: new Date(), error: null })
.where(and(eq(inventoryExportJobs.id, job.id), eq(inventoryExportJobs.status, 'PENDING')))
.returning();
return claimed ?? null;
});
},
async complete(id, output) {
const [completed] = await db.update(inventoryExportJobs)
.set({ status: 'COMPLETED', output, updatedAt: new Date(), error: null })
.where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING')))
.returning();
return completed ?? null;
},
async fail(id, error) {
const [failed] = await db.update(inventoryExportJobs)
.set({ status: 'FAILED', error, updatedAt: new Date() })
.where(and(eq(inventoryExportJobs.id, id), eq(inventoryExportJobs.status, 'PROCESSING')))
.returning();
return failed ?? null;
},
};
}
@@ -0,0 +1,98 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export async function registerAdminExportRoutes(app, options) {
const {
adminExportService,
authenticateAdmin = async () => {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
},
} = options;
app.addHook('preHandler', async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
}
request.admin = admin;
});
app.get('/exports/inventory.json', async (request, reply) => {
const data = await adminExportService.inventoryJson({
sellerId: request.admin.sellerId,
});
return reply
.type('application/json; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.json"')
.send(JSON.stringify(data));
});
app.get('/exports/inventory.csv', async (request, reply) => {
const data = await adminExportService.inventoryCsv({
sellerId: request.admin.sellerId,
});
return reply
.type('text/csv; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.csv"')
.send(data);
});
app.get('/exports/inventory.xls', async (request, reply) => {
const data = await adminExportService.inventoryExcel({
sellerId: request.admin.sellerId,
});
return reply
.type('application/vnd.ms-excel; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.xls"')
.send(data);
});
app.post('/exports/inventory.json/jobs', async (request, reply) => {
const job = await adminExportService.enqueueInventoryJson({
sellerId: request.admin.sellerId,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
app.get('/exports/jobs/:id', async (request, reply) => {
const job = await adminExportService.getExportJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
if (!job) {
throw createApplicationError('EXPORT_JOB_NOT_FOUND', 'Export job was not found.', 404);
}
return reply.send({
data: {
id: job.id,
format: job.format,
status: job.status,
error: job.error ?? null,
output: job.status === 'COMPLETED' ? job.output : null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
},
});
});
app.get('/exports/jobs/:id/download', async (request, reply) => {
const job = await adminExportService.getExportJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
if (!job) {
throw createApplicationError('EXPORT_JOB_NOT_FOUND', 'Export job was not found.', 404);
}
if (job.status !== 'COMPLETED' || !Array.isArray(job.output)) {
throw createApplicationError('EXPORT_NOT_READY', 'Export is not ready for download.', 409);
}
return reply
.type('application/json; charset=utf-8')
.header('content-disposition', 'attachment; filename="inventory.json"')
.send(JSON.stringify(job.output));
});
}
@@ -0,0 +1,61 @@
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
export function createAdminExportService({ inventoryItemService, inventoryExportJobRepository }) {
async function inventoryRows(sellerId) {
const inventoryItems = await inventoryItemService.listBySellerId(sellerId);
return inventoryItems.map(toAdminInventoryItemDto);
}
return {
async enqueueInventoryJson({ sellerId }) {
return inventoryExportJobRepository.create({ sellerId, format: 'JSON' });
},
async getExportJob({ sellerId, jobId }) {
return inventoryExportJobRepository.findByIdForSeller(jobId, sellerId);
},
async inventoryJson({ sellerId }) {
return inventoryRows(sellerId);
},
async inventoryCsv({ sellerId }) {
const rows = await inventoryRows(sellerId);
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
return [
columns.join(','),
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
].join('\r\n');
},
async inventoryExcel({ sellerId }) {
const rows = await inventoryRows(sellerId);
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
const header = columns.map((column) => excelCell(column, true)).join('');
const body = rows.map((row) => (
`<Row>${columns.map((column) => excelCell(row[column])).join('')}</Row>`
)).join('');
return `<?xml version="1.0"?>\n<?mso-application progid="Excel.Sheet"?>\n<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Styles><Style ss:ID="Header"><Font ss:Bold="1" /></Style></Styles><Worksheet ss:Name="Inventory"><Table><Row>${header}</Row>${body}</Table></Worksheet></Workbook>`;
},
};
}
function excelCell(value, header = false) {
const style = header ? ' ss:StyleID="Header"' : '';
return `<Cell${style}><Data ss:Type="String">${escapeXml(value)}</Data></Cell>`;
}
function escapeXml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function escapeCsvValue(value) {
const text = value == null ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
@@ -0,0 +1,19 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
export function validateImageMove(direction) {
if (direction !== 'up' && direction !== 'down') {
throw createApplicationError('IMAGE_MOVE_INVALID', 'Image move must be up or down.', 400);
}
}
// Input is the persisted display order. Return a reordered copy; boundary moves
// are no-ops. Persistence assigns contiguous positions after an actual move.
export function moveImageInOrder(images, imageId, direction) {
validateImageMove(direction);
const index = images.findIndex((image) => image.id === imageId);
const neighbor = index + (direction === 'up' ? -1 : 1);
if (index < 0 || neighbor < 0 || neighbor >= images.length) return images;
const reordered = [...images];
[reordered[index], reordered[neighbor]] = [reordered[neighbor], reordered[index]];
return reordered;
}
@@ -42,4 +42,17 @@ export function validateImageSignature(data, mimeType) {
} }
return true; return true;
} }
export function validateImageSortOrder(sortOrder) {
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 2147483647) {
throw createApplicationError('IMAGE_ORDER_INVALID', 'Image order must be an integer between 0 and 2147483647.', 400);
}
}
export function normalizeImageAltText(altText) {
if (altText === null) return null;
if (typeof altText !== 'string' || altText.trim().length > 1000) {
throw createApplicationError('IMAGE_ALT_TEXT_INVALID', 'Alt text must be text up to 1,000 characters or null.', 400);
}
return altText.trim();
}
@@ -31,6 +31,10 @@ export function transitionInventoryStatus(currentStatus, nextStatus) {
return nextStatus; return nextStatus;
} }
export function archiveInventoryItem(status) {
return transitionInventoryStatus(status, INVENTORY_STATUS.ARCHIVED);
}
export function assertInventoryItemEditable(status) { export function assertInventoryItemEditable(status) {
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) { if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
throw new Error('Inventory item is locked against ordinary edits.'); throw new Error('Inventory item is locked against ordinary edits.');
@@ -1,4 +1,6 @@
import { moveImageInOrder } from '../domain/image-order.js';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { import {
imageDerivativeJobs, imageDerivativeJobs,
@@ -23,6 +25,24 @@ export function createInventoryImageRepository(db) {
return createdImage; return createdImage;
}, },
async createForSellerWithLimit(image, sellerId, maxImages) {
return db.transaction(async (tx) => {
const [ownedItem] = await tx.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!ownedItem) return null;
const existing = await tx.select({ id: inventoryImages.id }).from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, image.inventoryItemId));
if (existing.length >= maxImages) {
throw createApplicationError('IMAGE_COUNT_LIMIT_REACHED', 'Maximum images per item reached.', 409);
}
const [createdImage] = await tx.insert(inventoryImages).values(image).returning();
return createdImage;
});
},
async countByInventoryItemId(inventoryItemId) { async countByInventoryItemId(inventoryItemId) {
const rows = await db const rows = await db
.select({ id: inventoryImages.id }) .select({ id: inventoryImages.id })
@@ -53,10 +73,42 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.inventoryItemId, inventoryItemId), eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(products.sellerId, sellerId), eq(products.sellerId, sellerId),
)) ))
.orderBy(inventoryImages.sortOrder) .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.then((rows) => rows.map(({ image }) => image)); .then((rows) => rows.map(({ image }) => image));
}, },
async moveForSeller(imageId, direction, sellerId) {
return db.transaction(async (tx) => {
// Lock the owning physical item first, serializing moves within a gallery.
const [owned] = await tx
.select({ inventoryItemId: inventoryItems.id })
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryImages.id, imageId), eq(products.sellerId, sellerId)))
.for('update', { of: inventoryItems });
if (!owned) return null;
const images = await tx.select().from(inventoryImages)
.where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId))
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
.for('update');
const target = images.find((image) => image.id === imageId);
if (!target) return null;
const reordered = moveImageInOrder(images, imageId, direction);
if (reordered === images) return target;
const updatedAt = new Date();
for (const [sortOrder, image] of reordered.entries()) {
if (image.sortOrder !== sortOrder) {
await tx.update(inventoryImages).set({ sortOrder, updatedAt })
.where(eq(inventoryImages.id, image.id));
}
}
const [moved] = await tx.select().from(inventoryImages).where(eq(inventoryImages.id, imageId));
return moved;
});
},
async updateOrderForSeller(imageId, sortOrder, sellerId) { async updateOrderForSeller(imageId, sortOrder, sellerId) {
const owned = await this.findByIdForSeller(imageId, sellerId); const owned = await this.findByIdForSeller(imageId, sellerId);
if (!owned) return null; if (!owned) return null;
@@ -135,7 +187,7 @@ export function createInventoryImageRepository(db) {
eq(inventoryImages.processingStatus, 'COMPLETED'), eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'), eq(imageDerivativeJobs.status, 'COMPLETED'),
)) ))
.orderBy(inventoryImages.sortOrder); .orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
}, },
async findPublicById(imageId) { async findPublicById(imageId) {
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; import { and, asc, count, desc, eq, exists, inArray, sql } from 'drizzle-orm';
import { import {
inventoryItems, inventoryItems,
@@ -35,6 +35,46 @@ export function createInventoryItemRepository(db) {
.then((rows) => rows.map(({ item }) => item)); .then((rows) => rows.map(({ item }) => item));
}, },
async archiveByIdsForSeller(inventoryItemIds, sellerId) {
if (inventoryItemIds.length === 0) return [];
return db
.update(inventoryItems)
.set({ status: 'ARCHIVED', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, inventoryItemIds),
eq(inventoryItems.status, 'ACTIVE'),
exists(
db
.select({ one: sql`1` })
.from(products)
.where(and(
eq(products.id, inventoryItems.productId),
eq(products.sellerId, sellerId),
)),
),
))
.returning();
},
async updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags) {
const rows = await db
.select({ productId: inventoryItems.productId })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(inArray(inventoryItems.id, inventoryItemIds), eq(products.sellerId, sellerId)));
const productIds = [...new Set(rows.map(({ productId }) => productId))];
const updated = [];
for (const productId of productIds) {
const [product] = await db.select().from(products).where(eq(products.id, productId));
const tags = [...new Set([...(product.tags ?? []), ...addTags.map((tag) => tag.toLowerCase())])]
.filter((tag) => !removeTags.map((value) => value.toLowerCase()).includes(tag));
const [result] = await db.update(products).set({ tags, updatedAt: new Date() }).where(eq(products.id, productId)).returning();
updated.push(result);
}
return updated;
},
async searchPublic({ page = 1, limit = 20 } = {}) { async searchPublic({ page = 1, limit = 20 } = {}) {
const conditions = [ const conditions = [
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -1,5 +1,5 @@
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image'; import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image'; import { createInventoryImageSchema, uploadInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
const data = await part.toBuffer(); const data = await part.toBuffer();
const fields = part.fields; const fields = part.fields;
const originalFilename = part.filename; const originalFilename = part.filename;
const result = createInventoryImageSchema.safeParse({ const result = uploadInventoryImageSchema.safeParse({
storageKey: `inventory/${request.params.id}/${randomUUID()}`, storageKey: `inventory/${request.params.id}/${randomUUID()}`,
originalFilename, originalFilename,
mimeType: part.mimetype, mimeType: part.mimetype,
fileSize: data.length, fileSize: data.length,
width: Number(fields.width?.value),
height: Number(fields.height?.value),
altText: fields.altText?.value, altText: fields.altText?.value,
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined, sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
isPrimary: fields.isPrimary?.value === 'true', isPrimary: fields.isPrimary?.value === 'true',
@@ -98,11 +96,25 @@ export async function registerAdminImageRoutes(app, options) {
return admin; return admin;
} }
app.patch('/images/:id/order', async (request) => { app.post('/images/:id/move', async (request, reply) => {
const admin = await authorizeAdminMutation(request); const admin = await authorizeAdminMutation(request);
const result = moveInventoryImageSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.move({
imageId: request.params.id,
direction: result.data.direction,
sellerId: admin.sellerId,
});
return { data: toAdminInventoryImageDto(image) };
});
app.patch('/images/:id/order', async (request, reply) => {
const admin = await authorizeAdminMutation(request);
const result = updateImageOrderSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.updateOrder({ const image = await inventoryImageService.updateOrder({
imageId: request.params.id, imageId: request.params.id,
sortOrder: request.body?.sortOrder, sortOrder: result.data.sortOrder,
sellerId: admin.sellerId, sellerId: admin.sellerId,
}); });
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
@@ -117,11 +129,13 @@ export async function registerAdminImageRoutes(app, options) {
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
}); });
app.patch('/images/:id/alt-text', async (request) => { app.patch('/images/:id/alt-text', async (request, reply) => {
const admin = await authorizeAdminMutation(request); const admin = await authorizeAdminMutation(request);
const result = updateImageAltTextSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const image = await inventoryImageService.updateAltText({ const image = await inventoryImageService.updateAltText({
imageId: request.params.id, imageId: request.params.id,
altText: request.body?.altText ?? null, altText: result.data.altText,
sellerId: admin.sellerId, sellerId: admin.sellerId,
}); });
return { data: toAdminInventoryImageDto(image) }; return { data: toAdminInventoryImageDto(image) };
@@ -1,5 +1,8 @@
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory'; import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory'; import {
bulkInventorySchema,
createInventoryItemSchema,
} from '@pokemon-inventory/validation/inventory';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -58,4 +61,44 @@ export async function registerAdminInventoryRoutes(app, options) {
app.get('/inventory', async (request) => ({ app.get('/inventory', async (request) => ({
data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto), data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto),
})); }));
app.post('/bulk', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const result = bulkInventorySchema.safeParse(request.body);
if (!result.success) {
return validationError(reply, result);
}
const inventoryItems = result.data.action === 'ARCHIVE'
? await inventoryItemService.archiveMany({
inventoryItemIds: result.data.inventoryItemIds,
sellerId: request.admin.sellerId,
})
: result.data.action === 'PRICING_REFRESH'
? await inventoryItemService.refreshPricingMany({
inventoryItemIds: result.data.inventoryItemIds,
providerId: result.data.providerId,
sellerId: request.admin.sellerId,
})
: await inventoryItemService.updateTagsMany({
inventoryItemIds: result.data.inventoryItemIds,
sellerId: request.admin.sellerId,
addTags: result.data.add ?? [],
removeTags: result.data.remove ?? [],
});
if (result.data.action === 'ARCHIVE') {
return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) });
}
if (result.data.action === 'TAGS') return reply.send({ data: inventoryItems });
return reply.code(202).send({
data: inventoryItems.map(({ id, status, createdAt }) => ({ id, status, createdAt })),
});
});
} }
@@ -1,13 +1,21 @@
import { validateImageMove } from '../domain/image-order.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js'; import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage }) { export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector, sellerSettingsRepository }) {
return { return {
async create(input) { async create(input) {
validateInventoryImageMetadata(input); validateInventoryImageMetadata(input);
const image = await inventoryImageRepository.createForSeller( const settingsRecord = await sellerSettingsRepository?.findBySellerId(input.sellerId);
const maxImages = settingsRecord?.settings?.inventory?.maxImagesPerItem ?? 10;
if (!Number.isInteger(maxImages) || maxImages < 1 || maxImages > 100) {
throw createApplicationError('IMAGE_COUNT_LIMIT_INVALID', 'Image count limit is invalid.', 500);
}
const createImage = inventoryImageRepository.createForSellerWithLimit ?? inventoryImageRepository.createForSeller;
const image = await createImage.call(inventoryImageRepository,
{ ...input, processingStatus: 'PENDING' }, { ...input, processingStatus: 'PENDING' },
input.sellerId, input.sellerId,
maxImages,
); );
if (!image) { if (!image) {
@@ -29,8 +37,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
async upload(input) { async upload(input) {
const { data, ...metadata } = input; const { data, ...metadata } = input;
validateInventoryImageMetadata(metadata);
validateImageSignature(data, metadata.mimeType); validateImageSignature(data, metadata.mimeType);
// Bound work before decoding; client dimensions and byte counts are not authoritative.
validateInventoryImageMetadata({ ...metadata, fileSize: data.length, width: 1, height: 1 });
let inspected;
try { inspected = await imageInspector.readMetadata(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
if (inspected.mimeType !== metadata.mimeType) {
throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image format does not match the declared MIME type.', 400);
}
Object.assign(metadata, { width: inspected.width, height: inspected.height, fileSize: data.length });
validateInventoryImageMetadata(metadata);
try { await imageInspector.validateDecoding(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
await imageStorage.put({ key: metadata.storageKey, data }); await imageStorage.put({ key: metadata.storageKey, data });
@@ -42,7 +61,13 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
} }
}, },
async move({ imageId, direction, sellerId }) {
validateImageMove(direction);
return requireOwnedImage(inventoryImageRepository.moveForSeller(imageId, direction, sellerId));
},
async updateOrder({ imageId, sortOrder, sellerId }) { async updateOrder({ imageId, sortOrder, sellerId }) {
validateImageSortOrder(sortOrder);
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId)); return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
}, },
@@ -51,7 +76,7 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
}, },
async updateAltText({ imageId, altText, sellerId }) { async updateAltText({ imageId, altText, sellerId }) {
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, altText, sellerId)); return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, normalizeImageAltText(altText), sellerId));
}, },
async delete({ imageId, sellerId }) { async delete({ imageId, sellerId }) {
@@ -81,4 +106,4 @@ async function requireOwnedImage(imagePromise) {
const image = await imagePromise; const image = await imagePromise;
if (!image) throw imageNotFoundError(); if (!image) throw imageNotFoundError();
return image; return image;
} }
@@ -1,10 +1,11 @@
import { createInventoryItem } from '../domain/inventory-item.js'; import { archiveInventoryItem, createInventoryItem } from '../domain/inventory-item.js';
import { createApplicationError } from '../../../shared/errors/application-error.js'; import { createApplicationError } from '../../../shared/errors/application-error.js';
export function createInventoryItemService({ export function createInventoryItemService({
conditionRepository, conditionRepository,
inventoryItemRepository, inventoryItemRepository,
productRepository, productRepository,
pricingService,
}) { }) {
return { return {
async listBySellerId(sellerId) { async listBySellerId(sellerId) {
@@ -35,5 +36,45 @@ export function createInventoryItemService({
return inventoryItemRepository.create(inventoryItem); return inventoryItemRepository.create(inventoryItem);
}, },
async archiveMany({ inventoryItemIds, sellerId }) {
const items = await inventoryItemRepository.listBySellerId(sellerId);
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
if (requestedItems.length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
requestedItems.forEach(({ status }) => archiveInventoryItem(status));
return inventoryItemRepository.archiveByIdsForSeller(inventoryItemIds, sellerId);
},
async refreshPricingMany({ inventoryItemIds, sellerId, providerId }) {
if (!pricingService) {
throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
}
const items = await inventoryItemRepository.listBySellerId(sellerId);
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
if (requestedItems.length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
return Promise.all(requestedItems.map((item) => pricingService.enqueueRefresh({
sellerId,
inventoryItemId: item.id,
providerId,
productId: item.productId,
conditionId: item.conditionId,
})));
},
async updateTagsMany({ inventoryItemIds, sellerId, addTags = [], removeTags = [] }) {
const items = await inventoryItemRepository.listBySellerId(sellerId);
if (items.filter(({ id }) => inventoryItemIds.includes(id)).length !== inventoryItemIds.length) {
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
}
return inventoryItemRepository.updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags);
},
}; };
} }
@@ -17,6 +17,8 @@ import {
marketValueOverrides, marketValueOverrides,
marketValuations, marketValuations,
products, products,
sellers,
sellerSettings,
} from "../../../infrastructure/database/schema.js"; } from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) { export function createListingRepository(db) {
@@ -75,6 +77,11 @@ export function createListingRepository(db) {
text = "", text = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -86,16 +93,26 @@ export function createListingRepository(db) {
const conditions = [ const conditions = [
inArray( inArray(
listings.status, listings.status,
includeSold status === "SOLD"
? ["AVAILABLE", "PENDING", "CLOSED"] ? ["CLOSED"]
: ["AVAILABLE", "PENDING"], : status === "PENDING"
? ["PENDING"]
: status === "AVAILABLE"
? ["AVAILABLE"]
: includeSold
? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"],
), ),
inArray( inArray(
inventoryItems.status, inventoryItems.status,
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"], status === "SOLD" ? ["SOLD"] : includeSold || status === "ALL" ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
), ),
]; ];
if (newlyAdded) {
conditions.push(sql`${inventoryItems.createdAt} >= NOW() - INTERVAL '30 days'`);
}
if (typeof category === "string" && category.trim().length > 0) { if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category)); conditions.push(eq(products.categoryId, category));
} }
@@ -104,6 +121,18 @@ export function createListingRepository(db) {
conditions.push(eq(inventoryItems.conditionId, conditionId)); conditions.push(eq(inventoryItems.conditionId, conditionId));
} }
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") { if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
} }
@@ -125,6 +154,7 @@ export function createListingRepository(db) {
ilike(products.description, `%${term}%`), ilike(products.description, `%${term}%`),
ilike(products.setName, `%${term}%`), ilike(products.setName, `%${term}%`),
ilike(products.brand, `%${term}%`), ilike(products.brand, `%${term}%`),
sql`${products.tags}::text ILIKE ${`%${term}%`}`,
), ),
), ),
), ),
@@ -133,8 +163,28 @@ export function createListingRepository(db) {
} }
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
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 = const sortOrder =
sort === "price_low" 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 ? listings.askingPriceMinor
: sort === "price_high" : sort === "price_high"
? desc(listings.askingPriceMinor) ? desc(listings.askingPriceMinor)
@@ -172,7 +222,12 @@ export function createListingRepository(db) {
async findPublicById(listingId) { async findPublicById(listingId) {
const [result] = await db const [result] = await db
.select({ listing: listings, condition: conditions }) .select({
listing: listings,
condition: conditions,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(listings) .from(listings)
.innerJoin( .innerJoin(
inventoryItems, inventoryItems,
@@ -180,6 +235,8 @@ export function createListingRepository(db) {
) )
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId)) .innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.innerJoin(sellers, eq(sellers.id, products.sellerId))
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
.where( .where(
and( and(
eq(listings.id, listingId), eq(listings.id, listingId),
@@ -214,6 +271,11 @@ export function createListingRepository(db) {
conditionCode: result.condition.code, conditionCode: result.condition.code,
marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null, marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null,
marketValueCurrency: override?.currency ?? valuation?.currency ?? null, marketValueCurrency: override?.currency ?? valuation?.currency ?? null,
marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null,
sellerDisplayName: result.sellerDisplayName,
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
}; };
}, },
@@ -1,5 +1,23 @@
import { createPricingProvider } from './pricing-provider.js'; import { createPricingProvider } from './pricing-provider.js';
export function assertProductionPricingProviderConfig({ fixtureEnabled, endpoint }) {
if (fixtureEnabled) {
throw new Error('The fixture pricing provider must be disabled in production.');
}
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
throw new Error('A production pricing provider endpoint is required.');
}
let parsedUrl;
try {
parsedUrl = new URL(endpoint);
} catch {
throw new Error('The production pricing provider endpoint must be a valid HTTPS URL.');
}
if (parsedUrl.protocol !== 'https:') {
throw new Error('The production pricing provider endpoint must use HTTPS.');
}
}
export function createHttpPricingProvider({ export function createHttpPricingProvider({
id, id,
endpoint, endpoint,
@@ -10,10 +28,21 @@ export function createHttpPricingProvider({
throw new Error('Pricing provider endpoint is required.'); throw new Error('Pricing provider endpoint is required.');
} }
let parsedUrl;
try {
parsedUrl = new URL(endpoint);
} catch {
throw new Error('Pricing provider endpoint must be a valid HTTPS URL.');
}
if (parsedUrl.protocol !== 'https:') {
throw new Error('Pricing provider endpoint must use HTTPS.');
}
return createPricingProvider({ return createPricingProvider({
id, id,
async observe(input) { async observe(input) {
const url = new URL(endpoint); const url = new URL(parsedUrl.toString());
for (const [key, value] of Object.entries(input)) { for (const [key, value] of Object.entries(input)) {
if (value) url.searchParams.set(key, value); if (value) url.searchParams.set(key, value);
} }
@@ -164,12 +164,11 @@ export function createPricingRefreshJobRepository(db) {
}); });
}, },
async purgeCompletedBefore(sellerId, cutoff) { async purgeCompletedBefore(cutoff) {
const deleted = await db const deleted = await db
.delete(pricingRefreshJobs) .delete(pricingRefreshJobs)
.where( .where(
and( and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "COMPLETED"), eq(pricingRefreshJobs.status, "COMPLETED"),
lte(pricingRefreshJobs.updatedAt, cutoff), lte(pricingRefreshJobs.updatedAt, cutoff),
), ),
@@ -1,5 +1,6 @@
import { import {
toAdminMarketValuationDto, toAdminMarketValuationDto,
toAdminMarketValuationSummaryDto,
toAdminPricingObservationDto, toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing"; } from "@pokemon-inventory/contracts/pricing";
import { import {
@@ -91,6 +92,14 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override }); return reply.code(201).send({ data: override });
}); });
app.get("/valuations", async (request) => ({
data: (
await pricingService.listValuations({
sellerId: request.admin.sellerId,
})
).map(toAdminMarketValuationSummaryDto),
}));
app.post("/inventory/:id/pricing/refresh", async (request, reply) => { app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
await authorizeUnsafeRequest?.( await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName], request.cookies[sessionCookieName],
@@ -203,6 +203,10 @@ export function createPricingService({
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? []; return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
}, },
async listValuations({ sellerId }) {
return pricingRepository.listLatestValuationsForSeller(sellerId);
},
async retryJob({ sellerId, jobId }) { async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller( const job = await pricingRefreshJobRepository?.retryForSeller(
jobId, jobId,
@@ -6,11 +6,13 @@ function requiredText(value, fieldName) {
return value.trim(); return value.trim();
} }
export function createProduct({ name, categoryId, sellerId }) { export function createProduct({ name, categoryId, sellerId, tags = [] }) {
const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()))];
return { return {
name: requiredText(name, 'Product name'), name: requiredText(name, 'Product name'),
categoryId: requiredText(categoryId, 'Category ID'), categoryId: requiredText(categoryId, 'Category ID'),
sellerId: requiredText(sellerId, 'Seller ID'), sellerId: requiredText(sellerId, 'Seller ID'),
tags: normalizedTags,
}; };
} }
@@ -4,6 +4,8 @@ import {
inventoryItems, inventoryItems,
listings, listings,
products, products,
sellers,
sellerSettings,
} from '../../../infrastructure/database/schema.js'; } from '../../../infrastructure/database/schema.js';
export function createProductRepository(db) { export function createProductRepository(db) {
@@ -24,10 +26,16 @@ export function createProductRepository(db) {
async findPublicBySlug(slug) { async findPublicBySlug(slug) {
const [result] = await db const [result] = await db
.select({ product: products }) .select({
product: products,
sellerDisplayName: sellers.displayName,
sellerSettings: sellerSettings.settings,
})
.from(products) .from(products)
.innerJoin(inventoryItems, eq(inventoryItems.productId, products.id)) .innerJoin(inventoryItems, eq(inventoryItems.productId, products.id))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(sellers, eq(sellers.id, products.sellerId))
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
.where(and( .where(and(
eq(products.slug, slug), eq(products.slug, slug),
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
@@ -35,7 +43,14 @@ export function createProductRepository(db) {
)) ))
.limit(1); .limit(1);
return result?.product ?? null; if (!result) return null;
return {
...result.product,
sellerDisplayName: result.sellerDisplayName,
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
};
}, },
async findBySellerIdAndSlug(sellerId, slug) { async findBySellerIdAndSlug(sellerId, slug) {
@@ -19,6 +19,11 @@ export function createSearchQuery({
query = "", query = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -36,6 +41,10 @@ export function createSearchQuery({
throw new Error("limit must be between 1 and 100"); throw new Error("limit must be between 1 and 100");
} }
if (status && !["AVAILABLE", "PENDING", "SOLD", "ALL"].includes(status)) {
throw new Error("status must be AVAILABLE, PENDING, SOLD, or ALL");
}
if ( if (
typeof minPriceMinor !== "undefined" && typeof minPriceMinor !== "undefined" &&
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0) (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
@@ -58,8 +67,8 @@ export function createSearchQuery({
throw new Error("minPriceMinor must be <= maxPriceMinor"); throw new Error("minPriceMinor must be <= maxPriceMinor");
} }
if (!["recent", "price_low", "price_high"].includes(sort)) { if (!["recent", "updated", "name_asc", "name_desc", "price_low", "price_high", "market_low", "market_high"].includes(sort)) {
throw new Error("sort must be recent, price_low, or price_high"); throw new Error("sort must be recent, updated, name_asc, name_desc, price_low, price_high, market_low, or market_high");
} }
return { return {
@@ -70,11 +79,22 @@ export function createSearchQuery({
...(typeof conditionId === "string" && conditionId.trim().length > 0 ...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() } ? { 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 minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}), ...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page, page,
limit, limit,
includeSold, includeSold,
...(status ? { status } : {}),
...(newlyAdded ? { newlyAdded: true } : {}),
...(sort !== "recent" ? { sort } : {}), ...(sort !== "recent" ? { 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();
});
}
@@ -32,6 +32,11 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query, query: request.query.query,
category: request.query.category, category: request.query.category,
conditionId: request.query.conditionId, conditionId: request.query.conditionId,
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( minPriceMinor: coerceNumber(
request.query.minPriceMinor, request.query.minPriceMinor,
"minPriceMinor", "minPriceMinor",
@@ -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 },
]),
);
}
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js';
test('exports seller inventory through the admin DTO boundary', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1');
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
createdAt: new Date('2026-09-10T00:00:00.000Z'),
updatedAt: new Date('2026-09-10T00:00:00.000Z'),
}];
},
},
});
assert.deepEqual(await service.inventoryJson({ sellerId: 'seller-1' }), [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
createdAt: '2026-09-10T00:00:00.000Z',
updatedAt: '2026-09-10T00:00:00.000Z',
}]);
});
test('exports inventory as escaped CSV rows', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId() {
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Front, "clean"\nback',
}];
},
},
});
assert.equal(await service.inventoryCsv({ sellerId: 'seller-1' }), [
'id,productId,conditionId,status,notes,createdAt,updatedAt',
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
].join('\r\n'));
});
test('exports inventory as an escaped Excel XML workbook', async () => {
const service = createAdminExportService({
inventoryItemService: {
async listBySellerId() {
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE', notes: 'A <note>' }];
},
},
});
const workbook = await service.inventoryExcel({ sellerId: 'seller-1' });
assert.match(workbook, /Excel\.Sheet/);
assert.match(workbook, /A &lt;note&gt;/);
assert.match(workbook, /<Cell ss:StyleID="Header"><Data ss:Type="String">id<\/Data><\/Cell>/);
});
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { createSharpImageInspector } from '../../src/infrastructure/images/sharp-image-inspector.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
function createService() {
const writes = [];
return { writes, service: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: { async put(input) { writes.push(input); }, async delete() {} },
inventoryImageRepository: { async createForSeller(input) { return { id: 'image-1', ...input }; } },
}) };
}
const metadata = { inventoryItemId: 'item-1', sellerId: 'seller-1', storageKey: 'test', originalFilename: 'test.png', width: 9999, height: 9999, fileSize: 1 };
test('inspects JPEG, PNG and WebP bytes and stores their true dimensions and size', async () => {
for (const [format, mimeType] of [['jpeg', 'image/jpeg'], ['png', 'image/png'], ['webp', 'image/webp']]) {
const data = await sharp({ create: { width: 12, height: 8, channels: 3, background: '#123456' } }).toFormat(format).toBuffer();
const { service, writes } = createService();
const image = await service.upload({ ...metadata, data, mimeType });
assert.equal(image.width, 12);
assert.equal(image.height, 8);
assert.equal(image.fileSize, data.length);
assert.equal(writes.length, 1);
}
});
test('rejects actual oversized dimensions, signature-only and truncated images before storage', async () => {
const oversized = await sharp({ create: { width: 10001, height: 1, channels: 3, background: 'white' } }).png().toBuffer();
const valid = await sharp({ create: { width: 12, height: 8, channels: 3, background: 'white' } }).png().toBuffer();
const incompletePixels = valid.subarray(0, valid.length - 15);
assert.equal((await createSharpImageInspector().readMetadata(incompletePixels)).width, 12);
for (const [data, code] of [[incompletePixels, 'IMAGE_DECODE_INVALID'], [oversized, 'IMAGE_DIMENSIONS_INVALID'], [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'IMAGE_DECODE_INVALID'], [valid.subarray(0, 40), 'IMAGE_DECODE_INVALID']]) {
const { service, writes } = createService();
await assert.rejects(() => service.upload({ ...metadata, width: 1, height: 1, data, mimeType: 'image/png' }), { code });
assert.deepEqual(writes, []);
}
});
test('checks true byte length before image inspection', async () => {
const data = Buffer.alloc(10 * 1024 * 1024 + 1);
Buffer.from([0xff, 0xd8, 0xff]).copy(data);
const service = createInventoryImageService({ imageInspector: { async readMetadata() { assert.fail('oversized input decoded'); } } });
await assert.rejects(() => service.upload({ ...metadata, data, mimeType: 'image/jpeg' }), { code: 'IMAGE_FILE_TOO_LARGE' });
});
@@ -35,3 +35,32 @@ test('manages owned image order, primary state, alt text, retry, and deletion',
assert.ok(calls.some(([action]) => action === 'delete-job')); assert.ok(calls.some(([action]) => action === 'delete-job'));
assert.ok(calls.some(([action]) => action === 'storage-delete')); assert.ok(calls.some(([action]) => action === 'storage-delete'));
}); });
test('moves an image through the owned atomic repository operation', async () => {
const service = createInventoryImageService({ inventoryImageRepository: {
async moveForSeller(id, direction, sellerId) {
assert.equal(direction, 'down');
assert.equal(sellerId, 'seller-1');
return id === 'owned' ? { id, sortOrder: 1 } : null;
},
} });
assert.deepEqual(await service.move({ imageId: 'owned', direction: 'down', sellerId: 'seller-1' }), { id: 'owned', sortOrder: 1 });
await assert.rejects(() => service.move({ imageId: 'missing', direction: 'down', sellerId: 'seller-1' }), { code: 'IMAGE_NOT_FOUND' });
await assert.rejects(() => service.move({ imageId: 'owned', direction: 'left', sellerId: 'seller-1' }), { code: 'IMAGE_MOVE_INVALID' });
});
test('validates image updates before persistence and normalizes valid alt text', async () => {
const { service, calls } = createService();
for (const sortOrder of [undefined, null, -1, 0.5, '1', NaN, Infinity, 2147483648]) {
await assert.rejects(() => service.updateOrder({ imageId: 'image-1', sortOrder, sellerId: 'seller-1' }), { code: 'IMAGE_ORDER_INVALID' });
}
for (const altText of [undefined, 123, {}, [], 'x'.repeat(1001)]) {
await assert.rejects(() => service.updateAltText({ imageId: 'image-1', altText, sellerId: 'seller-1' }), { code: 'IMAGE_ALT_TEXT_INVALID' });
}
assert.deepEqual(calls, []);
await service.updateOrder({ imageId: 'image-1', sortOrder: 2147483647, sellerId: 'seller-1' });
for (const [input, expected] of [[' Front ', 'Front'], [' ', ''], [null, null], ['x'.repeat(1000), 'x'.repeat(1000)]]) {
const result = await service.updateAltText({ imageId: 'image-1', altText: input, sellerId: 'seller-1' });
assert.equal(result.altText, expected);
}
});
@@ -19,6 +19,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
const stored = []; const stored = [];
const jobs = []; const jobs = [];
const service = createInventoryImageService({ const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: { imageStorage: {
async put(value) { stored.push(value); }, async put(value) { stored.push(value); },
async delete() {}, async delete() {},
@@ -38,6 +39,9 @@ test('stores validated upload bytes before persisting image metadata', async ()
assert.equal(stored.length, 1); assert.equal(stored.length, 1);
assert.deepEqual(stored[0], { key: input.storageKey, data: input.data }); assert.deepEqual(stored[0], { key: input.storageKey, data: input.data });
assert.equal(image.id, 'image-1'); assert.equal(image.id, 'image-1');
assert.equal(image.width, 2);
assert.equal(image.height, 3);
assert.equal(image.fileSize, input.data.length);
assert.equal(image.processingStatus, 'PENDING'); assert.equal(image.processingStatus, 'PENDING');
assert.equal('data' in image, false); assert.equal('data' in image, false);
assert.deepEqual(jobs, [{ assert.deepEqual(jobs, [{
@@ -50,6 +54,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
test('removes stored bytes when metadata persistence fails', async () => { test('removes stored bytes when metadata persistence fails', async () => {
const deleted = []; const deleted = [];
const service = createInventoryImageService({ const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: { imageStorage: {
async put() {}, async put() {},
async delete(key) { deleted.push(key); }, async delete(key) { deleted.push(key); },
@@ -64,4 +69,21 @@ test('removes stored bytes when metadata persistence fails', async () => {
(error) => error.code === 'INVENTORY_ITEM_NOT_FOUND', (error) => error.code === 'INVENTORY_ITEM_NOT_FOUND',
); );
assert.deepEqual(deleted, [input.storageKey]); assert.deepEqual(deleted, [input.storageKey]);
}); });
test('passes the seller image limit to the atomic repository operation', async () => {
let limit;
const service = createInventoryImageService({
sellerSettingsRepository: { async findBySellerId() { return { settings: { inventory: { maxImagesPerItem: 2 } } }; } },
inventoryImageRepository: {
async createForSellerWithLimit(image, sellerId, maxImages) {
assert.equal(sellerId, 'seller-id');
limit = maxImages;
return { id: 'image-1', ...image };
},
},
});
const image = await service.create({ ...input, data: undefined });
assert.equal(image.id, 'image-1');
assert.equal(limit, 2);
});
@@ -7,7 +7,13 @@ function createService({ product, condition }) {
return createInventoryItemService({ return createInventoryItemService({
productRepository: { async findById() { return product; } }, productRepository: { async findById() { return product; } },
conditionRepository: { async findById() { return condition; } }, conditionRepository: { async findById() { return condition; } },
inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } }, inventoryItemRepository: {
async create(inventoryItem) { return inventoryItem; },
async listBySellerId() { return []; },
async archiveByIdsForSeller(inventoryItemIds) {
return inventoryItemIds.map((id) => ({ id, status: 'ARCHIVED' }));
},
},
}); });
} }
@@ -31,6 +37,110 @@ test('creates an inventory item when its condition belongs to the product catego
}); });
}); });
test('archives owned active inventory items in bulk', async () => {
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [
{ id: 'item-1', status: 'ACTIVE' },
{ id: 'item-2', status: 'ACTIVE' },
];
},
async archiveByIdsForSeller(ids) {
return ids.map((id) => ({ id, status: 'ARCHIVED' }));
},
},
});
assert.deepEqual(
await service.archiveMany({ inventoryItemIds: ['item-1', 'item-2'], sellerId: 'seller-id' }),
[
{ id: 'item-1', status: 'ARCHIVED' },
{ id: 'item-2', status: 'ARCHIVED' },
],
);
});
test('does not archive sold inventory items', async () => {
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() { return [{ id: 'item-1', status: 'SOLD' }]; },
async archiveByIdsForSeller() { throw new Error('should not update'); },
},
});
await assert.rejects(
() => service.archiveMany({ inventoryItemIds: ['item-1'], sellerId: 'seller-id' }),
/Inventory status cannot transition from SOLD to ARCHIVED/,
);
});
test('enqueues pricing refresh jobs for owned inventory items', async () => {
const enqueued = [];
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE' }];
},
},
pricingService: {
async enqueueRefresh(input) {
enqueued.push(input);
return { id: 'job-1', status: 'PENDING' };
},
},
});
const jobs = await service.refreshPricingMany({
inventoryItemIds: ['item-1'],
sellerId: 'seller-id',
providerId: 'fixture-provider',
});
assert.deepEqual(enqueued, [{
sellerId: 'seller-id',
inventoryItemId: 'item-1',
providerId: 'fixture-provider',
productId: 'product-1',
conditionId: 'condition-1',
}]);
assert.deepEqual(jobs, [{ id: 'job-1', status: 'PENDING' }]);
});
test('updates Product tags for selected owned inventory items', async () => {
const updates = [];
const service = createInventoryItemService({
productRepository: { async findById() { return null; } },
conditionRepository: { async findById() { return null; } },
inventoryItemRepository: {
async listBySellerId() {
return [
{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1' },
{ id: 'item-2', productId: 'product-1', conditionId: 'condition-1' },
];
},
async updateProductTagsByIdsForSeller(...input) {
updates.push(input);
return [{ id: 'product-1', tags: ['binder', 'keep'] }];
},
},
});
assert.deepEqual(await service.updateTagsMany({
inventoryItemIds: ['item-1', 'item-2'],
sellerId: 'seller-id',
addTags: ['Binder'],
removeTags: ['sell'],
}), [{ id: 'product-1', tags: ['binder', 'keep'] }]);
assert.deepEqual(updates, [[['item-1', 'item-2'], 'seller-id', ['Binder'], ['sell']]]);
});
test('rejects conditions from another category', async () => { test('rejects conditions from another category', async () => {
const service = createService({ const service = createService({
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' }, product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
@@ -6,7 +6,10 @@ import {
createPricingProviderRegistry, createPricingProviderRegistry,
} from "../../src/modules/pricing/providers/pricing-provider.js"; } from "../../src/modules/pricing/providers/pricing-provider.js";
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js"; import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../../src/modules/pricing/providers/http-pricing-provider.js"; import {
assertProductionPricingProviderConfig,
createHttpPricingProvider,
} from "../../src/modules/pricing/providers/http-pricing-provider.js";
test("registers pricing providers behind a validated adapter contract", async () => { test("registers pricing providers behind a validated adapter contract", async () => {
const provider = createPricingProvider({ const provider = createPricingProvider({
@@ -58,6 +61,45 @@ test("fixture provider returns deterministic normalized observations", async ()
}); });
}); });
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 () => { test("http provider sends mapping context and normalizes its response", async () => {
let request; let request;
const provider = createHttpPricingProvider({ const provider = createHttpPricingProvider({
@@ -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, '');
});
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { moveImageInOrder } from '../../src/modules/inventory/domain/image-order.js';
test('moves one image next to its neighbor without changing the input order', () => {
const images = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(moveImageInOrder(images, 'b', 'up').map(({ id }) => id), ['b', 'a', 'c']);
assert.deepEqual(moveImageInOrder(images, 'b', 'down').map(({ id }) => id), ['a', 'c', 'b']);
assert.deepEqual(images.map(({ id }) => id), ['a', 'b', 'c']);
assert.equal(moveImageInOrder(images, 'a', 'up'), images);
assert.equal(moveImageInOrder(images, 'c', 'down'), images);
assert.throws(() => moveImageInOrder(images, 'a', 'left'), { code: 'IMAGE_MOVE_INVALID' });
});
+1
View File
@@ -14,6 +14,7 @@ test('creates a product with required product identity fields', () => {
name: 'Charizard ex', name: 'Charizard ex',
categoryId: 'category-id', categoryId: 'category-id',
sellerId: 'seller-id', sellerId: 'seller-id',
tags: [],
}); });
}); });
@@ -11,6 +11,9 @@ test("normalizes a public search query and keeps filter values within bounds", (
const query = createSearchQuery({ const query = createSearchQuery({
query: " Charizard ex ", query: " Charizard ex ",
category: "Cards", category: "Cards",
setName: "SV151",
eventName: "Worlds",
tag: "Binder",
minPriceMinor: 1000, minPriceMinor: 1000,
maxPriceMinor: 5000, maxPriceMinor: 5000,
page: 1, page: 1,
@@ -20,6 +23,9 @@ test("normalizes a public search query and keeps filter values within bounds", (
assert.equal(query.text, "charizard ex"); assert.equal(query.text, "charizard ex");
assert.equal(query.category, "Cards"); assert.equal(query.category, "Cards");
assert.equal(query.setName, "SV151");
assert.equal(query.eventName, "Worlds");
assert.equal(query.tag, "binder");
assert.equal(query.minPriceMinor, 1000); assert.equal(query.minPriceMinor, 1000);
assert.equal(query.maxPriceMinor, 5000); assert.equal(query.maxPriceMinor, 5000);
assert.equal(query.page, 1); assert.equal(query.page, 1);
@@ -57,12 +63,24 @@ test("builds a public query filter set that hides sold items by default", () =>
test("accepts only the supported public search sort values", () => { test("accepts only the supported public search sort values", () => {
assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low"); assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
assert.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( assert.throws(
() => createSearchQuery({ sort: "random" }), () => createSearchQuery({ sort: "random" }),
/sort must be recent/, /sort must be recent/,
); );
}); });
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", () => { test("normalizes whitespace and punctuation in free-text search terms", () => {
assert.equal( assert.equal(
normalizeSearchQuery(" Pikachu #199 / holo "), normalizeSearchQuery(" Pikachu #199 / holo "),
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js';
test('downloads seller-scoped inventory JSON for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: createAdminExportService({
inventoryItemService: {
async listBySellerId(sellerId) {
assert.equal(sellerId, 'seller-1');
return [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
}];
},
},
}),
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.json',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.json"');
assert.deepEqual(JSON.parse(response.body), [{
id: 'item-1',
productId: 'product-1',
conditionId: 'condition-1',
status: 'ACTIVE',
notes: 'Private note',
}]);
});
test('requires authentication for inventory JSON exports', async (testContext) => {
const app = buildApp({
adminExportService: createAdminExportService({
inventoryItemService: { async listBySellerId() { return []; } },
}),
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.json',
});
assert.equal(response.statusCode, 401);
assert.equal(response.json().error.code, 'UNAUTHORIZED');
});
test('downloads seller-scoped inventory CSV for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async inventoryCsv({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return 'id,status\r\n"item-1","ACTIVE"';
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'GET',
url: '/api/v1/admin/exports/inventory.csv',
});
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'text/csv; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
});
test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async inventoryExcel({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return '<?xml version="1.0"?><Workbook />';
},
},
});
testContext.after(() => app.close());
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8');
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"');
assert.equal(response.body, '<?xml version="1.0"?><Workbook />');
});
test('enqueues and polls an asynchronous inventory export', async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
adminExportService: {
async enqueueInventoryJson({ sellerId }) {
assert.equal(sellerId, 'seller-1');
return { id: 'job-1', status: 'PENDING', createdAt: '2026-09-10' };
},
async getExportJob({ sellerId, jobId }) {
assert.equal(sellerId, 'seller-1');
assert.equal(jobId, 'job-1');
return { id: jobId, format: 'JSON', status: 'COMPLETED', output: [{ id: 'item-1' }] };
},
},
});
testContext.after(() => app.close());
const enqueue = await app.inject({ method: 'POST', url: '/api/v1/admin/exports/inventory.json/jobs' });
const poll = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1' });
assert.equal(enqueue.statusCode, 202);
assert.equal(enqueue.json().data.id, 'job-1');
assert.equal(poll.statusCode, 200);
assert.deepEqual(poll.json().data.output, [{ id: 'item-1' }]);
const download = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1/download' });
assert.equal(download.statusCode, 200);
assert.equal(download.headers['content-disposition'], 'attachment; filename="inventory.json"');
assert.deepEqual(JSON.parse(download.body), [{ id: 'item-1' }]);
});
@@ -5,8 +5,9 @@ import { buildApp } from '../../src/app/build-app.js';
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js'; import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
import { createProductService } from '../../src/modules/products/services/product-service.js'; import { createProductService } from '../../src/modules/products/services/product-service.js';
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingService } = {}) {
const products = []; const products = [];
const inventoryItems = [];
const productRepository = { const productRepository = {
async create(product) { async create(product) {
const createdProduct = { const createdProduct = {
@@ -49,15 +50,30 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
}, },
inventoryItemRepository: { inventoryItemRepository: {
async create(inventoryItem) { async create(inventoryItem) {
return { const createdItem = {
id: 'inventory-1', id: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
...inventoryItem, ...inventoryItem,
status: 'ACTIVE', status: 'ACTIVE',
createdAt: new Date('2026-09-07T00:00:00.000Z'), createdAt: new Date('2026-09-07T00:00:00.000Z'),
updatedAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'),
}; };
inventoryItems.push(createdItem);
return createdItem;
},
async listBySellerId() {
return inventoryItems;
},
async archiveByIdsForSeller(ids) {
return inventoryItems.filter(({ id }) => ids.includes(id)).map((item) => {
item.status = 'ARCHIVED';
return item;
});
},
async updateProductTagsByIdsForSeller() {
return [];
}, },
}, },
pricingService,
}), }),
productService: createProductService({ productRepository }), productService: createProductService({ productRepository }),
}); });
@@ -146,6 +162,133 @@ test('creates inventory only for the authenticated sellers product', async (test
assert.equal(response.json().data.status, 'ACTIVE'); assert.equal(response.json().data.status, 'ACTIVE');
}); });
test('archives selected inventory through the authenticated bulk route', async (testContext) => {
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'ARCHIVE',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
},
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().data[0].status, 'ARCHIVED');
});
test('enqueues selected inventory pricing refreshes through the bulk route', async (testContext) => {
const enqueued = [];
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
pricingService: {
async enqueueRefresh(input) {
enqueued.push(input);
return {
id: 'job-1',
status: 'PENDING',
createdAt: new Date('2026-09-10T00:00:00.000Z'),
};
},
},
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'PRICING_REFRESH',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
providerId: 'fixture-provider',
},
});
assert.equal(response.statusCode, 202);
assert.deepEqual(enqueued, [{
sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f',
inventoryItemId: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
providerId: 'fixture-provider',
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
}]);
assert.equal(response.json().data[0].status, 'PENDING');
});
test('updates Product tags through the authenticated bulk route', async (testContext) => {
const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
});
testContext.after(() => app.close());
const productResponse = await app.inject({
method: 'POST',
url: '/api/v1/admin/products',
payload: {
name: 'Pikachu Plush',
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
tags: ['Keep'],
},
});
await app.inject({
method: 'POST',
url: '/api/v1/admin/inventory',
payload: {
productId: productResponse.json().data.id,
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
},
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/admin/bulk',
payload: {
action: 'TAGS',
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
add: ['Binder'],
remove: ['sell'],
},
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.json().data, []);
});
test('rejects an authenticated product write without a CSRF token', async (testContext) => { test('rejects an authenticated product write without a CSRF token', async (testContext) => {
const app = createTestApp({ const app = createTestApp({
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }), authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
function createTestApp({ authorizeUnsafeRequest } = {}) {
const views = [];
return buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest,
savedViewService: {
async list() { return views; },
async create({ view }) { const result = { id: 'view-1', ...view }; views.push(result); return result; },
async update({ view }) { return { id: 'view-1', ...view }; },
async delete() { return { id: 'view-1' }; },
},
});
}
test('creates, lists, updates, and deletes saved views', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const createResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Needs Attention', filters: { missingImages: true } } });
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/saved-views' });
const updateResponse = await app.inject({ method: 'PATCH', url: '/api/v1/admin/saved-views/view-1', payload: { name: 'Recent', filters: { sort: 'recent' } } });
const deleteResponse = await app.inject({ method: 'DELETE', url: '/api/v1/admin/saved-views/view-1' });
assert.equal(createResponse.statusCode, 201);
assert.equal(listResponse.statusCode, 200);
assert.equal(updateResponse.statusCode, 200);
assert.equal(deleteResponse.statusCode, 204);
});
test('protects saved-view mutations with CSRF validation', async (testContext) => {
const app = createTestApp({
authorizeUnsafeRequest: async () => {
const error = new Error('CSRF validation failed.');
error.code = 'FORBIDDEN';
error.statusCode = 403;
throw error;
},
});
testContext.after(() => app.close());
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Recent', filters: {} } });
assert.equal(response.statusCode, 403);
});
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { buildApp } from '../../src/app/build-app.js';
function createTestApp({ authorizeUnsafeRequest } = {}) {
let settings = { pricing: { defaultStrategy: 'median' } };
return buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest,
sellerSettingsService: createSellerSettingsService({ sellerSettingsRepository: {
async findBySellerId(sellerId) { assert.equal(sellerId, 'seller-1'); return { settings }; },
async upsert(sellerId, nextSettings) {
assert.equal(sellerId, 'seller-1');
settings = nextSettings;
return { settings };
},
} }),
});
}
test('reads and updates seller settings', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const readResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
const updateResponse = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { inventory: { maxImagesPerItem: 12 } },
});
assert.equal(readResponse.statusCode, 200);
assert.deepEqual(readResponse.json().data, { pricing: { defaultStrategy: 'median' } });
assert.equal(updateResponse.statusCode, 200);
assert.deepEqual(updateResponse.json().data, {
pricing: { defaultStrategy: 'median' },
inventory: { maxImagesPerItem: 12 },
});
});
test('protects settings updates with CSRF validation', async (testContext) => {
const app = createTestApp({
authorizeUnsafeRequest: async () => {
const error = new Error('CSRF validation failed.');
error.code = 'FORBIDDEN';
error.statusCode = 403;
throw error;
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { pricing: { defaultStrategy: 'median' } },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error.code, 'FORBIDDEN');
});
test('clears public contacts explicitly while omitted fields retain their values', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const patch = (general) => app.inject({ method: 'PATCH', url: '/api/v1/admin/settings', payload: { general } });
assert.equal((await patch({ publicContactEmail: ' owner@example.test ', publicContactPhone: '555-0100', publicDescription: 'Collector.' })).statusCode, 200);
const clearedEmail = await patch({ publicContactEmail: ' ' });
assert.equal(clearedEmail.statusCode, 200);
assert.deepEqual(clearedEmail.json().data.general, { publicContactEmail: '', publicContactPhone: '555-0100', publicDescription: 'Collector.' });
assert.equal((await patch({ publicContactPhone: '' })).statusCode, 200);
const read = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
assert.deepEqual(read.json().data.general, { publicContactEmail: '', publicContactPhone: '', publicDescription: 'Collector.' });
for (const general of [{ publicContactEmail: 'invalid' }, { publicContactEmail: null }, { publicContactPhone: 'x'.repeat(51) }]) {
assert.equal((await patch(general)).statusCode, 400);
}
});
@@ -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);
});
@@ -182,3 +182,38 @@ test("lists jobs and retries a failed job with CSRF protection", async (testCont
assert.equal(retryResponse.statusCode, 202); assert.equal(retryResponse.statusCode, 202);
assert.equal(csrfChecks, 1); assert.equal(csrfChecks, 1);
}); });
test("lists seller-scoped latest market valuations", async (testContext) => {
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
pricingService: {
async listValuations({ sellerId }) {
assert.equal(sellerId, "seller-1");
return [
{
id: "valuation-1",
inventoryItemId: "item-1",
valueMinor: 2500,
currency: "USD",
strategy: "MEDIAN",
confidence: "MEDIUM",
sourceCount: 2,
calculatedAt: new Date().toISOString(),
},
];
},
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: "GET",
url: "/api/v1/admin/valuations",
});
assert.equal(response.statusCode, 200);
const [valuation] = response.json().data;
assert.equal(valuation.inventoryItemId, "item-1");
assert.equal(valuation.valueMinor, 2500);
assert.equal(valuation.stale, false);
});
@@ -36,6 +36,10 @@ function createTestApp() {
conditionId: 'condition-1', conditionId: 'condition-1',
askingPriceMinor: 2499, askingPriceMinor: 2499,
status: 'PENDING', status: 'PENDING',
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
}; };
}, },
}, },
@@ -72,6 +76,9 @@ test('returns a public listing detail or a not-found response', async (testConte
assert.equal(detailResponse.statusCode, 200); assert.equal(detailResponse.statusCode, 200);
assert.equal('inventoryItemId' in detailResponse.json().data, false); assert.equal('inventoryItemId' in detailResponse.json().data, false);
assert.equal(detailResponse.json().data.sellerDisplayName, 'Pika Collectibles');
assert.equal(detailResponse.json().data.sellerDescription, 'Independent Pokémon collector.');
assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example');
assert.equal(missingResponse.statusCode, 404); assert.equal(missingResponse.statusCode, 404);
assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND'); assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND');
}); });
@@ -21,6 +21,10 @@ function createTestApp() {
name: 'Pikachu Plush', name: 'Pikachu Plush',
slug, slug,
description: 'A friendly plush.', description: 'A friendly plush.',
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: new Date('2026-09-07T00:00:00.000Z'), createdAt: new Date('2026-09-07T00:00:00.000Z'),
updatedAt: new Date('2026-09-07T00:00:00.000Z'), updatedAt: new Date('2026-09-07T00:00:00.000Z'),
}; };
@@ -50,6 +54,11 @@ test('returns a public product without private seller identity', async (testCont
cardNumber: null, cardNumber: null,
eventName: null, eventName: null,
releaseYear: null, releaseYear: null,
tags: [],
sellerDisplayName: 'Pika Collectibles',
sellerDescription: 'Independent Pokémon collector.',
sellerContactEmail: 'hello@pika.example',
sellerContactPhone: null,
createdAt: '2026-09-07T00:00:00.000Z', createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z', updatedAt: '2026-09-07T00:00:00.000Z',
}); });
@@ -12,6 +12,11 @@ test("returns public search results with pagination metadata", async (testContex
assert.equal(filters.includeSold, true); assert.equal(filters.includeSold, true);
assert.equal(filters.sort, "price_low"); assert.equal(filters.sort, "price_low");
assert.equal(filters.conditionId, "condition-1"); assert.equal(filters.conditionId, "condition-1");
assert.equal(filters.setName, "SV151");
assert.equal(filters.eventName, "Worlds");
assert.equal(filters.tag, "binder");
assert.equal(filters.status, "SOLD");
assert.equal(filters.newlyAdded, true);
return { return {
items: [{ id: "listing-1", status: "CLOSED" }], items: [{ id: "listing-1", status: "CLOSED" }],
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
@@ -23,7 +28,7 @@ test("returns public search results with pagination metadata", async (testContex
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1", url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1&setName=SV151&eventName=Worlds&tag=binder&status=SOLD&newlyAdded=true",
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
@@ -1,3 +1,5 @@
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from 'node:test';
@@ -17,6 +19,7 @@ import {
products, products,
securityAuditEvents, securityAuditEvents,
sellers, sellers,
sellerSettings,
sessions, sessions,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from '../../src/infrastructure/database/schema.js';
@@ -190,6 +193,23 @@ test('finds only products with active inventory and a public listing', async ()
product.id, product.id,
); );
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
await database.db.insert(sellerSettings).values({ sellerId: seller.id, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, 'owner@example.test');
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: '' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, null);
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactPhone: ' ' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, null);
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
await database.db await database.db
.update(listings) .update(listings)
.set({ status: 'CLOSED' }) .set({ status: 'CLOSED' })
@@ -1,3 +1,5 @@
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { after, beforeEach } from 'node:test'; import test, { after, beforeEach } from 'node:test';
@@ -12,6 +14,7 @@ import {
listings, listings,
products, products,
sellers, sellers,
sellerSettings,
users, users,
} from '../../src/infrastructure/database/schema.js'; } from '../../src/infrastructure/database/schema.js';
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js'; import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js';
@@ -148,6 +151,23 @@ test('searches public listings with pagination and hides sold items by default',
visible.items[0].id, visible.items[0].id,
); );
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
await database.db.insert(sellerSettings).values({ sellerId: product.sellerId, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, 'owner@example.test');
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: '' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, null);
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactPhone: ' ' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, null);
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
await database.db await database.db
.update(listings) .update(listings)
.set({ status: 'CLOSED' }) .set({ status: 'CLOSED' })
@@ -224,6 +244,17 @@ test('persists image metadata only for the owning seller and counts item images'
); );
}); });
test('enforces per-item image limits across concurrent inserts', async () => {
const { seller, inventoryItem } = await createInventoryFixture();
const image = (suffix) => ({ inventoryItemId: inventoryItem.id, storageKey: `inventory/${suffix}.jpg`, originalFilename: `${suffix}.jpg`, mimeType: 'image/jpeg', fileSize: 4, width: 2, height: 2, sortOrder: 0, isPrimary: false, processingStatus: 'PENDING' });
const results = await Promise.allSettled([
inventoryImageRepository.createForSellerWithLimit(image('a'), seller.id, 1),
inventoryImageRepository.createForSellerWithLimit(image('b'), seller.id, 1),
]);
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
assert.equal(results.filter((result) => result.status === 'rejected' && result.reason.code === 'IMAGE_COUNT_LIMIT_REACHED').length, 1);
});
test('reads and updates listings only through the owning seller scope', async () => { test('reads and updates listings only through the owning seller scope', async () => {
const { seller, category, condition, inventoryItem } = await createInventoryFixture(); const { seller, category, condition, inventoryItem } = await createInventoryFixture();
@@ -253,3 +284,65 @@ test('reads and updates listings only through the owning seller scope', async ()
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED'); const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
assert.equal(archivedListing.status, 'ARCHIVED'); assert.equal(archivedListing.status, 'ARCHIVED');
}); });
async function createImageOrderFixture() {
const { seller, inventoryItem } = await createInventoryFixture();
const images = [];
for (let index = 0; index < 3; index += 1) {
images.push(await inventoryImageRepository.createForSeller({
inventoryItemId: inventoryItem.id, storageKey: `move-${index}.jpg`, originalFilename: `move-${index}.jpg`,
mimeType: 'image/jpeg', fileSize: 100, width: 10, height: 10, sortOrder: 0,
isPrimary: index === 0,
}, seller.id));
}
return { seller, inventoryItem, images: await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id) };
}
test('moves images atomically with tied positions, ownership checks and boundary no-ops', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
const read = () => inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.equal(await inventoryImageRepository.moveForSeller(images[0].id, 'down', '00000000-0000-0000-0000-000000000000'), null);
assert.equal(await inventoryImageRepository.moveForSeller('00000000-0000-0000-0000-000000000000', 'down', seller.id), null);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual(await read(), images);
await inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id);
const moved = await read();
assert.deepEqual(moved.map(({ id }) => id), [images[1].id, images[0].id, images[2].id]);
assert.deepEqual(moved.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
assert.equal(moved.find(({ id }) => id === images[0].id).isPrimary, images[0].isPrimary);
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
assert.deepEqual((await read()).map(({ id }) => id), images.map(({ id }) => id));
});
test('rolls back every position if an image move fails after its first write', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
let writes = 0;
const failingRepository = createInventoryImageRepository({
transaction: (action) => database.db.transaction((tx) => action(new Proxy(tx, {
get(target, property) {
if (property === 'update') return (...args) => {
writes += 1;
if (writes === 2) throw new Error('Injected second write failure');
return target.update(...args);
};
const value = target[property];
return typeof value === 'function' ? value.bind(target) : value;
},
}))),
});
await assert.rejects(() => failingRepository.moveForSeller(images[0].id, 'down', seller.id), /Injected second write failure/);
assert.equal(writes, 2);
assert.deepEqual(await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id), images);
});
test('serializes concurrent moves within the same gallery', async () => {
const { seller, inventoryItem, images } = await createImageOrderFixture();
await Promise.all([
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
]);
const result = await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
assert.deepEqual(result.map(({ id }) => id), [images[1].id, images[2].id, images[0].id]);
assert.deepEqual(result.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
});
@@ -41,11 +41,12 @@ const pricingRefreshJobRepository = createPricingRefreshJobRepository(
const providerMappingRepository = createProviderMappingRepository(database.db); const providerMappingRepository = createProviderMappingRepository(database.db);
const saleRepository = createSaleRepository(database.db); const saleRepository = createSaleRepository(database.db);
async function createFixture() { async function createFixture(suffix = "") {
const fixtureName = suffix ? `transactions-${suffix}` : "transactions";
const [user] = await database.db const [user] = await database.db
.insert(users) .insert(users)
.values({ .values({
email: "transactions@example.test", email: `${fixtureName}@example.test`,
passwordHash: "not-a-real-password", passwordHash: "not-a-real-password",
}) })
.returning(); .returning();
@@ -53,14 +54,14 @@ async function createFixture() {
.insert(sellers) .insert(sellers)
.values({ .values({
userId: user.id, userId: user.id,
displayName: "Transactions Owner", displayName: `${fixtureName} Owner`,
}) })
.returning(); .returning();
const [category] = await database.db const [category] = await database.db
.insert(categories) .insert(categories)
.values({ .values({
name: "Transactions Cards", name: `${fixtureName} Cards`,
slug: "transactions-cards", slug: `${fixtureName}-cards`,
}) })
.returning(); .returning();
const [profile] = await database.db const [profile] = await database.db
@@ -84,8 +85,8 @@ async function createFixture() {
.values({ .values({
sellerId: seller.id, sellerId: seller.id,
categoryId: category.id, categoryId: category.id,
name: "Integration Card", name: `${fixtureName} Card`,
slug: "integration-card", slug: `${fixtureName}-card`,
}) })
.returning(); .returning();
const inventory = await database.db const inventory = await database.db
@@ -229,6 +230,44 @@ test("claims and completes a durable pricing refresh job", async () => {
assert.equal(user.email, "transactions@example.test"); assert.equal(user.email, "transactions@example.test");
}); });
test("purges completed pricing jobs across sellers before the cutoff", async () => {
const first = await createFixture();
const second = await createFixture("second");
const jobs = await Promise.all(
[first, second].map(({ seller, product, condition, inventory }) =>
pricingRefreshJobRepository.create({
sellerId: seller.id,
inventoryItemId: inventory[0].id,
productId: product.id,
conditionId: condition.id,
providerId: "integration",
}),
),
);
for (const job of jobs) {
await pricingRefreshJobRepository.claimNext();
await pricingRefreshJobRepository.complete(
job.id,
[{ priceMinor: 2500 }],
{ valueMinor: 2500 },
);
}
const deleted = await pricingRefreshJobRepository.purgeCompletedBefore(
new Date(Date.now() + 1000),
);
assert.equal(deleted, 2);
assert.equal(
(await pricingRefreshJobRepository.listForSeller(first.seller.id)).length,
0,
);
assert.equal(
(await pricingRefreshJobRepository.listForSeller(second.seller.id)).length,
0,
);
});
test("persists and disables seller-owned provider mappings", async () => { test("persists and disables seller-owned provider mappings", async () => {
const { seller, product } = await createFixture(); const { seller, product } = await createFixture();
const created = await providerMappingRepository.createForSeller( const created = await providerMappingRepository.createForSeller(
+7
View File
@@ -1,4 +1,11 @@
import { setup } from '@storybook/vue3';
import '../src/assets/styles/global.scss'; import '../src/assets/styles/global.scss';
import { storybookRouter } from './router.js';
setup((app) => {
app.use(storybookRouter);
});
export const globalTypes = { export const globalTypes = {
theme: { theme: {
+24
View File
@@ -0,0 +1,24 @@
import { createMemoryHistory, createRouter } from 'vue-router';
const stub = { render: () => null };
// Mirrors the named routes in src/router/index.js so RouterLink resolution
// works in isolated stories without importing the real app router/guards.
export const storybookRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'catalog', component: stub },
{ path: '/listings/:id', name: 'listing-detail', component: stub },
{ path: '/products/:slug', name: 'product-detail', component: stub },
{ path: '/login', name: 'login', component: stub },
{ path: '/admin', name: 'admin-home', component: stub },
{ path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: stub },
{ path: '/admin/inventory', name: 'admin-inventory', component: stub },
{ path: '/admin/listings', name: 'admin-listings', component: stub },
{ path: '/admin/sales', name: 'admin-sales', component: stub },
{ path: '/admin/acquisitions', name: 'admin-acquisitions', component: stub },
{ path: '/admin/settings', name: 'admin-settings', component: stub },
{ path: '/admin/saved-views', name: 'admin-saved-views', component: stub },
{ path: '/:pathMatch(.*)*', name: 'storybook-catch-all', component: stub },
],
});
+12
View File
@@ -0,0 +1,12 @@
// Forces a narrow viewport for stories that specifically exercise mobile-only
// CSS breakpoints, since @storybook/addon-viewport does not affect the
// headless iframe size that @storybook/test-runner navigates to directly.
export default {
async preVisit(page, context) {
if (context.id?.includes('--enlarged')) {
await page.setViewportSize({ width: 640, height: 900 });
} else if (context.id?.includes('--mobile')) {
await page.setViewportSize({ width: 320, height: 900 });
}
},
};
@@ -16,6 +16,7 @@
--color-success-500: #12a150; --color-success-500: #12a150;
--color-warning-500: #d88a00; --color-warning-500: #d88a00;
--color-danger-500: #e5484d; --color-danger-500: #e5484d;
--color-danger-solid: #b4232b;
--color-info-500: #1683c4; --color-info-500: #1683c4;
--color-success-text: #08783a; --color-success-text: #08783a;
--color-warning-text: #8a5700; --color-warning-text: #8a5700;
@@ -91,6 +92,9 @@
} }
[data-theme='dark'] { [data-theme='dark'] {
--color-success-text: #63d991;
--color-warning-text: #ffc766;
--color-danger-text: #ff9297;
color-scheme: dark; color-scheme: dark;
--color-background: #0b0d10; --color-background: #0b0d10;
--color-surface: #11161d; --color-surface: #11161d;
+13 -4
View File
@@ -9,6 +9,7 @@ button, input, select, textarea { font: inherit; }
button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } } button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } }
button { cursor: pointer; } button { cursor: pointer; }
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); } h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; border: 0; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
.catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); } .catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); }
.catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; } .catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; }
@@ -23,7 +24,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.results-heading span { color: var(--color-text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; } .results-heading span { color: var(--color-text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-top: var(--space-6); } .listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-top: var(--space-6); }
.detail-page { width: min(920px, calc(100% - var(--space-8))); margin: 0 auto; padding: var(--space-12) 0 var(--space-24); } .detail-page { width: min(920px, calc(100% - var(--space-8))); margin: 0 auto; padding: var(--space-12) 0 var(--space-24); }
.detail-back { color: var(--color-interactive); font-weight: var(--weight-bold); text-decoration: none; } .detail-back { color: var(--color-interactive-text); font-weight: var(--weight-bold); text-decoration: none; }
.detail-content { max-width: 680px; padding-top: var(--space-16); } .detail-content { max-width: 680px; padding-top: var(--space-16); }
.detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); } .detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); }
.detail-price { color: var(--color-text); font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; } .detail-price { color: var(--color-text); font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
@@ -33,15 +34,18 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.product-facts dt { color: var(--color-text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: 0.1em; text-transform: uppercase; } .product-facts dt { color: var(--color-text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: 0.1em; text-transform: uppercase; }
.product-facts dd { margin: 0; } .product-facts dd { margin: 0; }
.catalog-state { padding: var(--space-12) 0; color: var(--color-text-muted); } .catalog-state { padding: var(--space-12) 0; color: var(--color-text-muted); }
.catalog-state--error { color: var(--color-danger-500); } .catalog-state--error { color: var(--color-danger-text); }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-6); margin-top: var(--space-8); color: var(--color-text-muted); font-size: var(--text-sm); } .pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-6); margin-top: var(--space-8); color: var(--color-text-muted); font-size: var(--text-sm); }
.pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive); } .pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive-text); }
.pagination button:disabled { cursor: not-allowed; opacity: 0.45; } .pagination button:disabled { cursor: not-allowed; opacity: 0.45; }
.showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); } .showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); }
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); } .auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); }
.auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); } .auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); }
.auth-panel h1 { font-size: var(--text-2xl); } .auth-panel h1 { font-size: var(--text-2xl); }
.skip-link { position: absolute; left: var(--space-4); top: var(--space-2); z-index: 10; transform: translateY(-200%); background: var(--color-surface); color: var(--color-interactive-text); padding: var(--space-2) var(--space-4); border-radius: var(--radius-sm); }
.skip-link:focus { transform: translateY(0); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); } .admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); }
.admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); } .admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); }
.admin-sidebar h1 { font-size: var(--text-xl); } .admin-sidebar h1 { font-size: var(--text-xl); }
@@ -53,6 +57,9 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-page { max-width: 880px; } .admin-page { max-width: 880px; }
.admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; } .admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; }
.admin-workspace-grid form { display: grid; gap: var(--space-3); } .admin-workspace-grid form { display: grid; gap: var(--space-3); }
.admin-wizard { display: grid; gap: var(--space-4); margin: var(--space-8) 0; }
.admin-wizard__nav { display: flex; justify-content: space-between; gap: var(--space-3); }
.admin-wizard > [role="region"]:focus { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
.admin-list { margin-top: var(--space-8); } .admin-list { margin-top: var(--space-8); }
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); } .admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } .admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
@@ -75,7 +82,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); } .button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); }
.button--primary { background: var(--color-interactive); color: var(--color-on-interactive); } .button--primary { background: var(--color-interactive); color: var(--color-on-interactive); }
.button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); } .button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
.button--danger { background: var(--color-danger-text); color: var(--color-white); } .button--danger { background: var(--color-danger-solid); color: var(--color-white); }
.button:disabled { cursor: not-allowed; opacity: 0.45; } .button:disabled { cursor: not-allowed; opacity: 0.45; }
.swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); } .swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); }
.swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); } .swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
@@ -120,3 +127,5 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-shell__content { padding: var(--space-6); } .admin-shell__content { padding: var(--space-6); }
.admin-workspace-grid { grid-template-columns: 1fr; } .admin-workspace-grid { grid-template-columns: 1fr; }
} }
.public-page-focus:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: -3px; }
@@ -0,0 +1,18 @@
.catalog-sort-select {
display: grid;
gap: var(--space-1);
}
.catalog-sort-select span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.catalog-sort-select select {
min-height: 44px;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
color: var(--color-text);
}
@@ -0,0 +1,7 @@
import CatalogSortSelect from './CatalogSortSelect.vue';
export default { title: 'Atoms/CatalogSortSelect', component: CatalogSortSelect };
export const RecentlyAdded = { args: { modelValue: 'recent' } };
export const NameAscending = { args: { modelValue: 'name_asc' } };
export const MarketValueDescending = { args: { modelValue: 'market_high' } };
@@ -0,0 +1,30 @@
<script setup>
defineProps({
modelValue: { type: String, required: true },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-sort-select">
<span>Sort</span>
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option value="recent">Recently added</option>
<option value="updated">Recently updated</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
<option value="price_low">Price: low to high</option>
<option value="price_high">Price: high to low</option>
<option value="market_low">Market value: low to high</option>
<option value="market_high">Market value: high to low</option>
</select>
</label>
</template>
<style scoped lang="scss">
@use './CatalogSortSelect.scss';
</style>
@@ -0,0 +1,18 @@
.catalog-text-filter {
display: grid;
gap: var(--space-1);
}
.catalog-text-filter span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.catalog-text-filter input {
min-height: 44px;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
color: var(--color-text);
}
@@ -0,0 +1,6 @@
import CatalogTextFilter from './CatalogTextFilter.vue';
export default { title: 'Atoms/CatalogTextFilter', component: CatalogTextFilter };
export const Set = { args: { label: 'Set', modelValue: '', placeholder: 'SV151' } };
export const Tag = { args: { label: 'Tag', modelValue: 'binder' } };
@@ -0,0 +1,25 @@
<script setup>
defineProps({
label: { type: String, required: true },
modelValue: { type: String, required: true },
placeholder: { type: String, default: '' },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-text-filter">
<span>{{ label }}</span>
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
</label>
</template>
<style scoped lang="scss">
@use './CatalogTextFilter.scss';
</style>
@@ -0,0 +1,14 @@
.catalog-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
color: var(--color-text);
font-size: var(--text-sm);
}
.catalog-toggle input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--color-interactive);
}
@@ -0,0 +1,6 @@
import CatalogToggle from './CatalogToggle.vue';
export default { title: 'Atoms/CatalogToggle', component: CatalogToggle };
export const NewlyAdded = { args: { label: 'Newly added', modelValue: false } };
export const IncludeSold = { args: { label: 'Include sold', modelValue: true } };
@@ -0,0 +1,23 @@
<script setup>
defineProps({
modelValue: { type: Boolean, required: true },
label: { type: String, required: true },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-toggle">
<input
:checked="modelValue"
type="checkbox"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span>{{ label }}</span>
</label>
</template>
<style scoped lang="scss">
@use './CatalogToggle.scss';
</style>
@@ -2,4 +2,5 @@ import ConditionBadge from './ConditionBadge.vue';
export default { title: 'Atoms/ConditionBadge', component: ConditionBadge }; export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
export const Unselected = { args: { label: 'Near Mint', selected: false } }; export const Unselected = { args: { label: 'Near Mint', selected: false } };
export const Selected = { args: { label: 'Near Mint', selected: true } }; export const Selected = { args: { label: 'Near Mint', selected: true } };
export const ReadOnly = { args: { label: 'Near Mint', interactive: false } };
@@ -1,15 +1,17 @@
<script setup> <script setup>
defineProps({ defineProps({
label: { type: String, required: true }, label: { type: String, required: true },
interactive: { type: Boolean, default: true },
selected: { type: Boolean, default: false }, selected: { type: Boolean, default: false },
}); });
defineEmits(['select']); defineEmits(['select']);
</script> </script>
<template> <template>
<button :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')"> <button v-if="interactive" :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
{{ label }} {{ label }}
</button> </button>
<span v-else class="condition">{{ label }}</span>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -15,13 +15,13 @@ const computedBlockers = computed(() => props.blockers.length ? props.blockers :
</script> </script>
<template> <template>
<article class="admin-listing-status"> <article class="admin-listing-status" :aria-labelledby="`listing-title-${listing.id}`">
<div class="admin-listing-status__identity"> <div class="admin-listing-status__identity">
<p class="catalog-kicker">Listing</p> <p class="catalog-kicker">Listing</p>
<h3>{{ listing.productName }}</h3> <h3 :id="`listing-title-${listing.id}`">{{ listing.productName }}</h3>
<span class="fixture-label">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span> <span class="fixture-label">${{ (listing.askingPriceMinor / 100).toFixed(2) }}</span>
</div> </div>
<span class="status" :class="`status--${listing.status.toLowerCase()}`">{{ listing.status }}</span> <span class="status" role="status" :class="`status--${listing.status.toLowerCase()}`">{{ listing.status }}</span>
<ul v-if="computedBlockers.length" class="admin-listing-status__blockers" aria-label="Publication blockers"> <ul v-if="computedBlockers.length" class="admin-listing-status__blockers" aria-label="Publication blockers">
<li v-for="blocker in computedBlockers" :key="blocker">{{ blocker }}</li> <li v-for="blocker in computedBlockers" :key="blocker">{{ blocker }}</li>
</ul> </ul>
@@ -2,6 +2,6 @@
.image-gallery__stage { display: grid; place-items: center; min-height: 280px; border-radius: var(--radius-xl); background: var(--color-stage); overflow: hidden; } .image-gallery__stage { display: grid; place-items: center; min-height: 280px; border-radius: var(--radius-xl); background: var(--color-stage); overflow: hidden; }
.image-gallery__stage img { width: 100%; height: 100%; min-height: 280px; object-fit: contain; } .image-gallery__stage img { width: 100%; height: 100%; min-height: 280px; object-fit: contain; }
.image-gallery__placeholder { color: var(--color-text-muted); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--weight-bold); } .image-gallery__placeholder { color: var(--color-text-muted); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--weight-bold); }
.image-gallery__controls { display: flex; gap: var(--space-2); } .image-gallery__controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.image-gallery__controls button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); } .image-gallery__controls button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); }
.image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive); } .image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); }
@@ -1,3 +1,4 @@
import { expect, within } from '@storybook/test';
import ImageGallery from "./ImageGallery.vue"; import ImageGallery from "./ImageGallery.vue";
export default { title: "Molecules/ImageGallery", component: ImageGallery }; export default { title: "Molecules/ImageGallery", component: ImageGallery };
@@ -47,3 +48,10 @@ export const LoadingImage = {
}, },
parameters: { chromatic: { delay: 1000 } }, parameters: { chromatic: { delay: 1000 } },
}; };
export const BrokenApiImageFallback = {
args: { images: [{ id: 'broken-api', url: '/fixtures/missing-api-image.webp', altText: 'Back of collectible' }] },
play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByRole('img', { name: 'Collectible image unavailable' })).toBeVisible();
},
};
@@ -69,12 +69,12 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
class="image-gallery__placeholder" class="image-gallery__placeholder"
role="img" role="img"
:aria-label=" :aria-label="
props.images[selectedIndex]?.src (props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? 'Collectible image unavailable' ? 'Collectible image unavailable'
: 'No collectible image available' : 'No collectible image available'
" "
>{{ >{{
props.images[selectedIndex]?.src (props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? "IMAGE UNAVAILABLE" ? "IMAGE UNAVAILABLE"
: (props.images[selectedIndex]?.label ?? "IMAGE") : (props.images[selectedIndex]?.label ?? "IMAGE")
}}</span }}</span
@@ -83,6 +83,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
<div <div
v-if="props.images.length > 1" v-if="props.images.length > 1"
class="image-gallery__controls" class="image-gallery__controls"
role="group"
aria-label="Gallery images" aria-label="Gallery images"
> >
<button <button
@@ -90,7 +91,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
:key="image.id" :key="image.id"
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
type="button" type="button"
:aria-label="`Show image ${index + 1}: ${image.alt}`" :aria-label="`Show image ${index + 1}: ${image.altText || image.alt || image.label || 'Collectible image'}`"
:aria-current="index === selectedIndex ? 'true' : undefined" :aria-current="index === selectedIndex ? 'true' : undefined"
@click=" @click="
selectedIndex = index; selectedIndex = index;
@@ -0,0 +1,9 @@
.inventory-copy-step {
align-content: start;
}
.inventory-copy-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@@ -0,0 +1,12 @@
import InventoryCopyStep from './InventoryCopyStep.vue';
export default { title: 'Molecules/InventoryCopyStep', component: InventoryCopyStep };
const products = [{ id: 'product-1', name: 'Charizard ex #199/165' }];
const conditions = [
{ id: 'condition-1', name: 'Near Mint', code: 'NM' },
{ id: 'condition-2', name: 'Lightly Played', code: 'LP' },
];
export const Empty = { args: { productId: '', conditionId: '', products, conditions } };
export const Selected = { args: { productId: 'product-1', conditionId: 'condition-1', products, conditions } };
@@ -0,0 +1,58 @@
<script setup>
defineProps({
productId: { type: String, required: true },
conditionId: { type: String, required: true },
products: { type: Array, required: true },
conditions: { type: Array, required: true },
isSaving: { type: Boolean, default: false },
});
defineEmits(['update:productId', 'update:conditionId', 'submit']);
</script>
<template>
<form id="workflow-inventory" class="showcase-panel inventory-copy-step" @submit.prevent="$emit('submit')">
<div>
<p class="catalog-kicker">Step 3</p>
<h2>Add a Physical Copy</h2>
<p class="inventory-copy-step__intro">Track each owned copy separately so condition, images, pricing, and sale history stay precise.</p>
</div>
<label class="fixture-label" for="inventory-product">Product</label>
<select
id="inventory-product"
:value="productId"
class="fixture-input"
required
aria-describedby="inventory-product-help"
@change="$emit('update:productId', $event.target.value)"
>
<option value="" disabled>Select a product</option>
<option v-for="product in products" :key="product.id" :value="product.id">
{{ product.name }}
</option>
</select>
<p id="inventory-product-help">Select the product represented by this physical copy.</p>
<label class="fixture-label" for="inventory-condition">Condition</label>
<select
id="inventory-condition"
:value="conditionId"
class="fixture-input"
required
aria-describedby="inventory-condition-help"
@change="$emit('update:conditionId', $event.target.value)"
>
<option value="" disabled>Select a condition</option>
<option v-for="condition in conditions" :key="condition.id" :value="condition.id">
{{ condition.name }} ({{ condition.code }})
</option>
</select>
<p id="inventory-condition-help">Choose the condition for this individual copy.</p>
<button class="button button--primary" type="submit" :disabled="isSaving">
Create physical copy
</button>
</form>
</template>
<style scoped lang="scss">
@use './InventoryCopyStep.scss';
</style>
@@ -0,0 +1,58 @@
.inventory-images-step {
display: grid;
gap: var(--space-4);
grid-column: 1 / -1;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
background: var(--color-surface);
}
.inventory-images-step__header {
display: flex;
justify-content: space-between;
gap: var(--space-4);
}
.inventory-images-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.inventory-images-step__count {
align-self: start;
color: var(--color-warning-text);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
.inventory-images-step__list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.inventory-images-step__list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-top: var(--border-thin) solid var(--color-border);
}
.inventory-images-step__list a {
color: var(--color-interactive-text);
font-weight: var(--weight-semibold);
}
@media (max-width: 640px) {
.inventory-images-step__header,
.inventory-images-step__list li {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,15 @@
import InventoryImagesStep from './InventoryImagesStep.vue';
export default { title: 'Molecules/InventoryImagesStep', component: InventoryImagesStep };
export const MissingImages = {
args: {
missingImages: 2,
inventory: [
{ id: 'copy-1', status: 'ACTIVE' },
{ id: 'copy-2', status: 'ACTIVE' },
],
},
};
export const Empty = { args: { missingImages: 0, inventory: [] } };
@@ -0,0 +1,30 @@
<script setup>
defineProps({
inventory: { type: Array, required: true },
missingImages: { type: Number, default: 0 },
});
</script>
<template>
<section id="workflow-images" class="inventory-images-step" aria-labelledby="inventory-images-step-title">
<div class="inventory-images-step__header">
<div>
<p class="catalog-kicker">Step 4</p>
<h2 id="inventory-images-step-title">Add Images</h2>
<p class="inventory-images-step__intro">Add clear photos to each physical copy before publishing its listing.</p>
</div>
<span class="inventory-images-step__count">{{ missingImages }} missing</span>
</div>
<p v-if="!inventory.length" class="state state--empty">Create a physical copy before adding images.</p>
<ul v-else class="inventory-images-step__list">
<li v-for="item in inventory" :key="item.id">
<span>{{ item.id }} · {{ item.status }}</span>
<a :href="`/admin/inventory/${item.id}/images`">Manage images</a>
</li>
</ul>
</section>
</template>
<style scoped lang="scss">
@use './InventoryImagesStep.scss';
</style>
@@ -0,0 +1,84 @@
.inventory-workflow {
margin: var(--space-6) 0 var(--space-8);
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
overflow-x: auto;
}
.inventory-workflow__steps {
display: grid;
grid-template-columns: repeat(6, minmax(10rem, 1fr));
min-width: 680px;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.inventory-workflow__step {
border-right: var(--border-thin) solid var(--color-border);
}
.inventory-workflow__step:last-child {
border-right: 0;
}
.inventory-workflow__link {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-height: 76px;
width: 100%;
padding: var(--space-3);
border: 0;
background: none;
color: var(--color-text-subtle);
text-align: left;
font: inherit;
cursor: pointer;
}
.inventory-workflow__link:hover,
.inventory-workflow__link:focus-visible {
box-shadow: inset 3px 0 0 var(--color-interactive);
}
.inventory-workflow__step--active .inventory-workflow__link {
box-shadow: inset 3px 0 0 var(--color-interactive);
}
.inventory-workflow__index {
display: grid;
flex: 0 0 28px;
place-items: center;
width: 28px;
height: 28px;
border: var(--border-thin) solid var(--color-border-strong);
border-radius: var(--radius-full);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: var(--weight-bold);
}
.inventory-workflow__step strong,
.inventory-workflow__step small {
display: block;
}
.inventory-workflow__step small {
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.inventory-workflow__step--complete .inventory-workflow__index {
border-color: var(--color-success-500);
color: var(--color-success-text);
}
.inventory-workflow__step--current .inventory-workflow__index {
border-color: var(--color-interactive);
color: var(--color-interactive-text);
box-shadow: var(--glow-selection);
}
@@ -0,0 +1,42 @@
import { expect, fn, userEvent, within } from '@storybook/test';
import InventoryWorkflowStepper from './InventoryWorkflowStepper.vue';
export default { title: 'Molecules/InventoryWorkflowStepper', component: InventoryWorkflowStepper };
export const InProgress = {
args: {
activeStepId: 'workflow-mapping',
steps: [
{ id: 'workflow-product', label: 'Product', description: 'Identify item', status: 'complete' },
{ id: 'workflow-mapping', label: 'Sources', description: 'Link markets', status: 'current' },
{ id: 'workflow-inventory', label: 'Copy', description: 'Add physical copy', status: 'pending' },
{ id: 'workflow-images', label: 'Images', description: 'Add photos', status: 'pending' },
{ id: 'workflow-pricing', label: 'Pricing', description: 'Check value', status: 'pending' },
{ id: 'workflow-listing', label: 'List', description: 'Publish offer', status: 'pending' },
],
},
};
export const Complete = {
args: {
activeStepId: 'workflow-listing',
steps: [
{ id: 'workflow-product', label: 'Product', description: 'Identify item', status: 'complete' },
{ id: 'workflow-mapping', label: 'Sources', description: 'Link markets', status: 'complete' },
{ id: 'workflow-inventory', label: 'Copy', description: 'Add physical copy', status: 'complete' },
{ id: 'workflow-images', label: 'Images', description: 'Add photos', status: 'complete' },
{ id: 'workflow-pricing', label: 'Pricing', description: 'Check value', status: 'complete' },
{ id: 'workflow-listing', label: 'List', description: 'Publish offer', status: 'complete' },
],
},
};
export const SelectStepWithKeyboard = {
args: { ...InProgress.args, onSelect: fn() },
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /Copy/ }));
await expect(args.onSelect).toHaveBeenCalledWith('workflow-inventory');
},
};
@@ -0,0 +1,33 @@
<script setup>
defineProps({
steps: {
type: Array,
required: true,
},
activeStepId: {
type: String,
default: '',
},
});
defineEmits(['select']);
</script>
<template>
<nav class="inventory-workflow" aria-label="Inventory creation workflow">
<ol class="inventory-workflow__steps">
<li v-for="(step, index) in steps" :key="step.id" :class="['inventory-workflow__step', `inventory-workflow__step--${step.status}`, { 'inventory-workflow__step--active': activeStepId === step.id }]">
<button type="button" class="inventory-workflow__link" :aria-current="activeStepId === step.id ? 'step' : undefined" @click="$emit('select', step.id)">
<span class="inventory-workflow__index">{{ index + 1 }}</span>
<span>
<strong>{{ step.label }}</strong>
<small>{{ step.description }}</small>
</span>
</button>
</li>
</ol>
</nav>
</template>
<style scoped lang="scss">
@use './InventoryWorkflowStepper.scss';
</style>
@@ -43,5 +43,6 @@ export const KeyboardReachable = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await userEvent.tab(); await userEvent.tab();
await expect(canvas.getByRole('link')).toHaveFocus(); await expect(canvas.getByRole('link')).toHaveFocus();
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/listings/listing-1');
}, },
}; };
@@ -1,4 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
const route = useRoute();
const catalogOrigin = computed(() => route.name === 'catalog' && route.fullPath !== '/' ? route.fullPath : undefined);
import PriceDisplay from '@/components/atoms/PriceDisplay.vue'; import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
import StatusBadge from '@/components/atoms/StatusBadge.vue'; import StatusBadge from '@/components/atoms/StatusBadge.vue';
@@ -11,14 +16,14 @@ defineProps({
</script> </script>
<template> <template>
<a class="listing-card" :href="`/listings/${encodeURIComponent(listing.id)}`"> <RouterLink class="listing-card" :to="{ name: 'listing-detail', params: { id: listing.id }, query: catalogOrigin ? { from: catalogOrigin } : {} }">
<span class="listing-card__mark" aria-hidden="true">PK</span> <span class="listing-card__mark" aria-hidden="true">PK</span>
<span> <span>
<StatusBadge :status="listing.status" /> <StatusBadge :status="listing.status" />
<span class="listing-card__name">{{ listing.productName }}</span> <span class="listing-card__name">{{ listing.productName }}</span>
<PriceDisplay :amount-minor="listing.askingPriceMinor" /> <PriceDisplay :amount-minor="listing.askingPriceMinor" />
</span> </span>
</a> </RouterLink>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -0,0 +1,9 @@
.pricing-review-step {
align-content: start;
}
.pricing-review-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@@ -0,0 +1,19 @@
import PricingReviewStep from './PricingReviewStep.vue';
export default { title: 'Molecules/PricingReviewStep', component: PricingReviewStep };
const inventory = [{ id: 'copy-1' }, { id: 'copy-2' }];
export const Empty = {
args: {
inventoryItemId: '', provider: 'fixture-provider', sourceIdentifier: '', observedPrice: '',
overridePrice: '', overrideReason: '', inventory, refreshStatus: '',
},
};
export const Refreshing = {
args: {
inventoryItemId: 'copy-1', provider: 'fixture-provider', sourceIdentifier: 'fixture-copy-1', observedPrice: '2500',
overridePrice: '', overrideReason: '', inventory, refreshStatus: 'PROCESSING', isSaving: true,
},
};
@@ -0,0 +1,58 @@
<script setup>
defineProps({
inventoryItemId: { type: String, required: true },
provider: { type: String, required: true },
sourceIdentifier: { type: String, required: true },
observedPrice: { type: String, required: true },
overridePrice: { type: String, required: true },
overrideReason: { type: String, required: true },
inventory: { type: Array, required: true },
refreshStatus: { type: String, default: '' },
isSaving: { type: Boolean, default: false },
});
defineEmits([
'update:inventoryItemId',
'update:provider',
'update:sourceIdentifier',
'update:observedPrice',
'update:overridePrice',
'update:overrideReason',
'calculate',
'refresh',
'override',
]);
</script>
<template>
<form id="workflow-pricing" class="showcase-panel pricing-review-step" @submit.prevent="$emit('calculate')">
<div>
<p class="catalog-kicker">Step 5</p>
<h2>Check Pricing</h2>
<p class="pricing-review-step__intro">Record a source, refresh market data, or add a reasoned private override before listing.</p>
</div>
<label class="fixture-label" for="pricing-inventory">Inventory copy</label>
<select id="pricing-inventory" :value="inventoryItemId" class="fixture-input" required @change="$emit('update:inventoryItemId', $event.target.value)">
<option value="" disabled>Select a copy</option>
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }}</option>
</select>
<label class="fixture-label" for="pricing-provider">Provider</label>
<input id="pricing-provider" :value="provider" class="fixture-input" required @input="$emit('update:provider', $event.target.value)" />
<label class="fixture-label" for="pricing-source">Source identifier</label>
<input id="pricing-source" :value="sourceIdentifier" class="fixture-input" required @input="$emit('update:sourceIdentifier', $event.target.value)" />
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
<input id="pricing-price" :value="observedPrice" class="fixture-input" type="number" min="1" required aria-describedby="pricing-money-help" @input="$emit('update:observedPrice', $event.target.value)" />
<p id="pricing-money-help">Enter observed and manual values in whole cents.</p>
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
<button class="button button--secondary" type="button" :disabled="isSaving" @click="$emit('refresh')">Refresh from provider<span v-if="refreshStatus"> ({{ refreshStatus }})</span></button>
<label class="fixture-label" for="override-price">Manual value (cents)</label>
<input id="override-price" :value="overridePrice" class="fixture-input" type="number" min="0" aria-describedby="pricing-money-help" @input="$emit('update:overridePrice', $event.target.value)" />
<label class="fixture-label" for="override-reason">Override reason</label>
<input id="override-reason" :value="overrideReason" class="fixture-input" minlength="3" @input="$emit('update:overrideReason', $event.target.value)" />
<button class="button button--secondary" type="button" :disabled="isSaving" @click="$emit('override')">Save manual value</button>
</form>
</template>
<style scoped lang="scss">
@use './PricingReviewStep.scss';
</style>
@@ -0,0 +1,9 @@
.product-identity-step {
align-content: start;
}
.product-identity-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@@ -0,0 +1,22 @@
import ProductIdentityStep from './ProductIdentityStep.vue';
export default { title: 'Molecules/ProductIdentityStep', component: ProductIdentityStep };
export const Empty = {
args: {
productName: '',
categoryId: '',
categories: [
{ id: 'cards', name: 'Trading cards' },
{ id: 'merchandise', name: 'Merchandise' },
],
},
};
export const Filled = {
args: {
productName: 'Charizard ex #199/165',
categoryId: 'cards',
categories: [{ id: 'cards', name: 'Trading cards' }],
},
};
@@ -0,0 +1,52 @@
<script setup>
defineProps({
productName: { type: String, required: true },
categoryId: { type: String, required: true },
categories: { type: Array, required: true },
isSaving: { type: Boolean, default: false },
});
defineEmits(['update:productName', 'update:categoryId', 'submit']);
</script>
<template>
<form id="workflow-product" class="showcase-panel product-identity-step" @submit.prevent="$emit('submit')">
<div>
<p class="catalog-kicker">Step 1</p>
<h2>Identify the Product</h2>
<p class="product-identity-step__intro">Create the shared product record before adding a physical copy.</p>
</div>
<label class="fixture-label" for="product-name">Name</label>
<input
id="product-name"
:value="productName"
class="fixture-input"
required
aria-describedby="product-name-help"
@input="$emit('update:productName', $event.target.value)"
/>
<p id="product-name-help">Use the recognizable product name collectors will search for.</p>
<label class="fixture-label" for="product-category">Category</label>
<select
id="product-category"
:value="categoryId"
class="fixture-input"
required
aria-describedby="product-category-help"
@change="$emit('update:categoryId', $event.target.value)"
>
<option value="" disabled>Select a category</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
<p id="product-category-help">Choose the catalog category that defines valid conditions for this product.</p>
<button class="button button--primary" type="submit" :disabled="isSaving">
Create product
</button>
</form>
</template>
<style scoped lang="scss">
@use './ProductIdentityStep.scss';
</style>
@@ -0,0 +1,9 @@
.provider-mapping-step {
align-content: start;
}
.provider-mapping-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@@ -0,0 +1,17 @@
import ProviderMappingStep from './ProviderMappingStep.vue';
export default { title: 'Molecules/ProviderMappingStep', component: ProviderMappingStep };
export const Empty = {
args: {
productId: '', providerId: '', mappingType: 'PRODUCT', externalIdentifier: '', searchQuery: '',
products: [{ id: 'product-1', name: 'Charizard ex #199/165' }],
},
};
export const SearchMapping = {
args: {
productId: 'product-1', providerId: 'ebay', mappingType: 'SEARCH', externalIdentifier: '', searchQuery: 'Charizard ex 199/165 near mint',
products: [{ id: 'product-1', name: 'Charizard ex #199/165' }],
},
};
@@ -0,0 +1,63 @@
<script setup>
defineProps({
productId: { type: String, required: true },
providerId: { type: String, required: true },
mappingType: { type: String, required: true },
externalIdentifier: { type: String, required: true },
searchQuery: { type: String, required: true },
products: { type: Array, required: true },
isSaving: { type: Boolean, default: false },
});
defineEmits([
'update:productId',
'update:providerId',
'update:mappingType',
'update:externalIdentifier',
'update:searchQuery',
'product-change',
'submit',
]);
</script>
<template>
<form id="workflow-mapping" class="showcase-panel provider-mapping-step" @submit.prevent="$emit('submit')">
<div>
<p class="catalog-kicker">Step 2</p>
<h2>Link Market Sources</h2>
<p class="provider-mapping-step__intro">Connect a provider product or search so future copies can reuse the source.</p>
</div>
<label class="fixture-label" for="mapping-product">Product</label>
<select
id="mapping-product"
:value="productId"
class="fixture-input"
required
@change="$emit('update:productId', $event.target.value); $emit('product-change', $event.target.value)"
>
<option value="" disabled>Select a product</option>
<option v-for="product in products" :key="product.id" :value="product.id">
{{ product.name }}
</option>
</select>
<label class="fixture-label" for="mapping-provider">Provider ID</label>
<input id="mapping-provider" :value="providerId" class="fixture-input" required placeholder="ebay" aria-describedby="mapping-provider-help" @input="$emit('update:providerId', $event.target.value)" />
<p id="mapping-provider-help">Use the configured provider identifier, such as ebay.</p>
<label class="fixture-label" for="mapping-type">Mapping type</label>
<select id="mapping-type" :value="mappingType" class="fixture-input" @change="$emit('update:mappingType', $event.target.value)">
<option value="PRODUCT">Product</option>
<option value="SEARCH">Search</option>
</select>
<label class="fixture-label" for="mapping-external">External identifier</label>
<input id="mapping-external" :value="externalIdentifier" class="fixture-input" aria-describedby="mapping-external-help" @input="$emit('update:externalIdentifier', $event.target.value)" />
<p id="mapping-external-help">For Product mappings, enter the provider’s product identifier.</p>
<label class="fixture-label" for="mapping-query">Search query</label>
<input id="mapping-query" :value="searchQuery" class="fixture-input" aria-describedby="mapping-query-help" @input="$emit('update:searchQuery', $event.target.value)" />
<p id="mapping-query-help">For Search mappings, enter the provider search text.</p>
<button class="button button--primary" type="submit" :disabled="isSaving">Save provider mapping</button>
</form>
</template>
<style scoped lang="scss">
@use './ProviderMappingStep.scss';
</style>
@@ -0,0 +1,9 @@
.review-list-step {
align-content: start;
}
.review-list-step__intro {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--text-sm);
}

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