Compare commits
77
Commits
660690bf34
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3132e80646 | ||
|
|
a81f882831 | ||
|
|
6840c9e6ba | ||
|
|
3dbb2e8001 | ||
|
|
312b1e5bbc | ||
|
|
cc45a4f119 | ||
|
|
7c6e00fca3 | ||
|
|
255848fcaa | ||
|
|
f1190fa806 | ||
|
|
7786671c00 | ||
|
|
0f04ef45fe | ||
|
|
38406f7c60 | ||
|
|
e3db8df92e | ||
|
|
fef7c0a339 | ||
|
|
4217009384 | ||
|
|
bb8499e10c | ||
|
|
18a99595e5 | ||
|
|
98d2adac17 | ||
|
|
eb43cef872 | ||
|
|
f54223966c | ||
|
|
e6510c0317 | ||
|
|
cce395baf6 | ||
|
|
0e81fc4022 | ||
|
|
72e9a79b67 | ||
|
|
c5a07b2b5a | ||
|
|
d321d8c679 | ||
|
|
9a790ce294 | ||
|
|
73c430b8a5 | ||
|
|
a02965de4a | ||
|
|
94c37250d1 | ||
|
|
e630a89e8d | ||
|
|
54a8d87293 | ||
|
|
6b528350c0 | ||
|
|
c85693c919 | ||
|
|
934aad2afd | ||
|
|
1fa4bb1c7a | ||
|
|
6c99ffaeba | ||
|
|
54be51ed5d | ||
|
|
c9f6a58516 | ||
|
|
7d20aedd14 | ||
|
|
ae1605c82d | ||
|
|
82b012faa1 | ||
|
|
ff6f3359aa | ||
|
|
13702bb3ef | ||
|
|
7ad5635900 | ||
|
|
3e29ca0529 | ||
|
|
8ff547feaa | ||
|
|
ffb86ce21a | ||
|
|
81705af6e3 | ||
|
|
c9ad3a11af | ||
|
|
673701fa7a | ||
|
|
b036092775 | ||
|
|
0008af9f2c | ||
|
|
96d3bf831e | ||
|
|
ea6a839b69 | ||
|
|
1e270f6184 | ||
|
|
e069dcaa40 | ||
|
|
0cd1b9ff2c | ||
|
|
fce30513d4 | ||
|
|
4f0e3d89f6 | ||
|
|
f837324cf5 | ||
|
|
ffd239d27c | ||
|
|
ec322df25c | ||
|
|
ad8f675f5e | ||
|
|
7c4885cf65 | ||
|
|
abb0b64217 | ||
|
|
cf61ec3c2c | ||
|
|
24f51212e5 | ||
|
|
d6b4dbec5f | ||
|
|
7c9518923d | ||
|
|
d3821e9c76 | ||
|
|
099ec2fe72 | ||
|
|
e0d231facc | ||
|
|
12eced8b19 | ||
|
|
aaa9e2a370 | ||
|
|
79df9d5c88 | ||
|
|
76e40d7289 |
@@ -9,6 +9,8 @@ PRICING_FIXTURE_PROVIDER_ENABLED=false
|
||||
PRICING_HTTP_PROVIDER_ID=http-provider
|
||||
PRICING_HTTP_PROVIDER_ENDPOINT=
|
||||
PRICING_HTTP_PROVIDER_TOKEN=
|
||||
PRICING_JOB_RETENTION_DAYS=30
|
||||
PRICING_JOB_CLEANUP_INTERVAL_MS=3600000
|
||||
PASSWORD_RESET_URL=http://localhost:5173/reset-password
|
||||
SMTP_FROM=no-reply@example.test
|
||||
SMTP_URL=smtp://localhost:1025
|
||||
|
||||
@@ -47,6 +47,19 @@ jobs:
|
||||
DATABASE_URL=postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test npm run db:migrate
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm test
|
||||
- name: Run worker tests
|
||||
run: npm run test --workspace=@pokemon-inventory/worker
|
||||
- name: Audit production dependencies
|
||||
run: npm audit --omit=dev
|
||||
- name: Validate deployment scripts
|
||||
run: |
|
||||
sh -n scripts/*.sh
|
||||
sh scripts/deployment-health-wait.test.sh
|
||||
sh scripts/verify-postgres-backup.test.sh
|
||||
sh scripts/restore-postgres.test.sh
|
||||
sh scripts/restore-image-storage.test.sh
|
||||
sh scripts/smoke-production.test.sh
|
||||
sh scripts/validate-production-env.test.sh
|
||||
- name: Run integration tests
|
||||
env:
|
||||
TEST_DATABASE_URL: postgresql://pokemon_inventory:ci-password@localhost:5433/pokemon_inventory_test
|
||||
|
||||
@@ -132,7 +132,7 @@ With `npm run dev` running, configured local admin credentials, and demo lookup
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
The workflow covers login, admin overview loading, product creation, physical inventory creation, and navigation to image management. It skips when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured.
|
||||
The authenticated workflows cover login, admin overview loading, product and physical inventory creation, image management, listing publication, sales and audited sale correction, settings updates, Saved Views CRUD, and export controls. They skip when `INITIAL_ADMIN_EMAIL` or `INITIAL_ADMIN_PASSWORD` is not configured.
|
||||
|
||||
## Initial Administrator
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ import { registerAdminAcquisitionRoutes } from "../modules/acquisitions/routes/a
|
||||
import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-dashboard-routes.js";
|
||||
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
|
||||
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
||||
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
|
||||
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
|
||||
import { registerAdminSavedViewRoutes } from "../modules/search/routes/admin-saved-view-routes.js";
|
||||
|
||||
export function buildApp(dependencies) {
|
||||
const app = Fastify();
|
||||
@@ -88,6 +91,18 @@ export function buildApp(dependencies) {
|
||||
...dependencies,
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminExportRoutes, {
|
||||
...dependencies,
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminSettingsRoutes, {
|
||||
...dependencies,
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerAdminSavedViewRoutes, {
|
||||
...dependencies,
|
||||
prefix: "/api/v1/admin",
|
||||
});
|
||||
app.register(registerPublicSearchRoutes, {
|
||||
...dependencies,
|
||||
prefix: "/api/v1",
|
||||
|
||||
+45
-16
@@ -1,3 +1,4 @@
|
||||
import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
|
||||
import dotenv from "dotenv";
|
||||
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 { createPricingProviderRegistry } from "../modules/pricing/providers/pricing-provider.js";
|
||||
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
|
||||
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
|
||||
import {
|
||||
assertProductionPricingProviderConfig,
|
||||
createHttpPricingProvider,
|
||||
} from "../modules/pricing/providers/http-pricing-provider.js";
|
||||
import { createAdminExportService } from "../modules/exports/services/admin-export-service.js";
|
||||
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
|
||||
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
|
||||
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
|
||||
import { createSavedViewRepository } from "../modules/search/repositories/saved-view-repository.js";
|
||||
import { createSavedViewService } from "../modules/search/services/saved-view-service.js";
|
||||
|
||||
function parsePort(value) {
|
||||
const port = Number.parseInt(value ?? "3000", 10);
|
||||
@@ -64,6 +74,12 @@ function parsePort(value) {
|
||||
}
|
||||
|
||||
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(
|
||||
process.env.IMAGE_STORAGE_ROOT ?? ".local/image-storage",
|
||||
import.meta.url,
|
||||
@@ -89,6 +105,12 @@ async function startServer() {
|
||||
const providerMappingRepository = createProviderMappingRepository(
|
||||
database.db,
|
||||
);
|
||||
const sellerSettingsService = createSellerSettingsService({
|
||||
sellerSettingsRepository: createSellerSettingsRepository(database.db),
|
||||
});
|
||||
const savedViewService = createSavedViewService({
|
||||
savedViewRepository: createSavedViewRepository(database.db),
|
||||
});
|
||||
const providers = [];
|
||||
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
||||
providers.push(createFixturePricingProvider());
|
||||
@@ -101,6 +123,18 @@ async function startServer() {
|
||||
}),
|
||||
);
|
||||
const pricingProviders = createPricingProviderRegistry(providers);
|
||||
const pricingService = createPricingService({
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
providerMappingRepository,
|
||||
pricingProviderRegistry: pricingProviders,
|
||||
});
|
||||
const inventoryItemService = createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
pricingService,
|
||||
productRepository,
|
||||
});
|
||||
const userRepository = createUserRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
@@ -146,11 +180,13 @@ async function startServer() {
|
||||
keyPrefix: "pokemon-inventory:auth:password-reset:",
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
inventoryItemService,
|
||||
adminExportService: createAdminExportService({
|
||||
inventoryExportJobRepository: createInventoryExportJobRepository(database.db),
|
||||
inventoryItemService,
|
||||
}),
|
||||
sellerSettingsService,
|
||||
savedViewService,
|
||||
catalogLookupRepository,
|
||||
publicImageService,
|
||||
publicInventoryService: createPublicInventoryService({
|
||||
@@ -158,9 +194,11 @@ async function startServer() {
|
||||
publicImageService,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({
|
||||
imageInspector: createSharpImageInspector(),
|
||||
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||
imageDerivativeJobRepository,
|
||||
inventoryImageRepository,
|
||||
sellerSettingsRepository,
|
||||
}),
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
@@ -170,11 +208,7 @@ async function startServer() {
|
||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||
dashboardService: createDashboardService({
|
||||
productService: createProductService({ productRepository }),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}),
|
||||
inventoryItemService,
|
||||
listingService: createListingService({
|
||||
listingRepository,
|
||||
inventoryImageRepository,
|
||||
@@ -184,12 +218,7 @@ async function startServer() {
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
}),
|
||||
pricingService: createPricingService({
|
||||
pricingRepository,
|
||||
pricingRefreshJobRepository,
|
||||
providerMappingRepository,
|
||||
pricingProviderRegistry: pricingProviders,
|
||||
}),
|
||||
pricingService,
|
||||
providerMappingService: createProviderMappingService({
|
||||
providerMappingRepository,
|
||||
}),
|
||||
|
||||
@@ -61,6 +61,49 @@ export const sellers = pgTable(
|
||||
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
|
||||
);
|
||||
|
||||
export const sellerSettings = pgTable("seller_settings", {
|
||||
sellerId: uuid("seller_id")
|
||||
.primaryKey()
|
||||
.references(() => sellers.id, { onDelete: "cascade" }),
|
||||
settings: jsonb("settings").default({}).notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const inventoryExportJobs = pgTable(
|
||||
"inventory_export_jobs",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id, { onDelete: "cascade" }),
|
||||
format: text("format").notNull(),
|
||||
status: text("status").default("PENDING").notNull(),
|
||||
output: jsonb("output"),
|
||||
error: text("error"),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index("inventory_export_jobs_status_created_at_index").on(
|
||||
table.status,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const savedViews = pgTable(
|
||||
"saved_views",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
sellerId: uuid("seller_id")
|
||||
.notNull()
|
||||
.references(() => sellers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
filters: jsonb("filters").default({}).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("saved_views_seller_name_unique").on(table.sellerId, table.name)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
@@ -218,6 +261,7 @@ export const products = pgTable(
|
||||
cardNumber: text("card_number"),
|
||||
eventName: text("event_name"),
|
||||
releaseYear: integer("release_year"),
|
||||
tags: text("tags").array().default([]).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeCsvValue(value) {
|
||||
const text = value == null ? '' : String(value);
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function validateImageMove(direction) {
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
throw createApplicationError('IMAGE_MOVE_INVALID', 'Image move must be up or down.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Input is the persisted display order. Return a reordered copy; boundary moves
|
||||
// are no-ops. Persistence assigns contiguous positions after an actual move.
|
||||
export function moveImageInOrder(images, imageId, direction) {
|
||||
validateImageMove(direction);
|
||||
const index = images.findIndex((image) => image.id === imageId);
|
||||
const neighbor = index + (direction === 'up' ? -1 : 1);
|
||||
if (index < 0 || neighbor < 0 || neighbor >= images.length) return images;
|
||||
const reordered = [...images];
|
||||
[reordered[index], reordered[neighbor]] = [reordered[neighbor], reordered[index]];
|
||||
return reordered;
|
||||
}
|
||||
@@ -42,4 +42,17 @@ export function validateImageSignature(data, mimeType) {
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export function validateImageSortOrder(sortOrder) {
|
||||
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 2147483647) {
|
||||
throw createApplicationError('IMAGE_ORDER_INVALID', 'Image order must be an integer between 0 and 2147483647.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeImageAltText(altText) {
|
||||
if (altText === null) return null;
|
||||
if (typeof altText !== 'string' || altText.trim().length > 1000) {
|
||||
throw createApplicationError('IMAGE_ALT_TEXT_INVALID', 'Alt text must be text up to 1,000 characters or null.', 400);
|
||||
}
|
||||
return altText.trim();
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ export function transitionInventoryStatus(currentStatus, nextStatus) {
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
export function archiveInventoryItem(status) {
|
||||
return transitionInventoryStatus(status, INVENTORY_STATUS.ARCHIVED);
|
||||
}
|
||||
|
||||
export function assertInventoryItemEditable(status) {
|
||||
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
|
||||
throw new Error('Inventory item is locked against ordinary edits.');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { moveImageInOrder } from '../domain/image-order.js';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
import {
|
||||
imageDerivativeJobs,
|
||||
@@ -23,6 +25,24 @@ export function createInventoryImageRepository(db) {
|
||||
return createdImage;
|
||||
},
|
||||
|
||||
async createForSellerWithLimit(image, sellerId, maxImages) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [ownedItem] = await tx.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)))
|
||||
.for('update', { of: inventoryItems });
|
||||
if (!ownedItem) return null;
|
||||
const existing = await tx.select({ id: inventoryImages.id }).from(inventoryImages)
|
||||
.where(eq(inventoryImages.inventoryItemId, image.inventoryItemId));
|
||||
if (existing.length >= maxImages) {
|
||||
throw createApplicationError('IMAGE_COUNT_LIMIT_REACHED', 'Maximum images per item reached.', 409);
|
||||
}
|
||||
const [createdImage] = await tx.insert(inventoryImages).values(image).returning();
|
||||
return createdImage;
|
||||
});
|
||||
},
|
||||
|
||||
async countByInventoryItemId(inventoryItemId) {
|
||||
const rows = await db
|
||||
.select({ id: inventoryImages.id })
|
||||
@@ -53,10 +73,42 @@ export function createInventoryImageRepository(db) {
|
||||
eq(inventoryImages.inventoryItemId, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
))
|
||||
.orderBy(inventoryImages.sortOrder)
|
||||
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
|
||||
.then((rows) => rows.map(({ image }) => image));
|
||||
},
|
||||
|
||||
async moveForSeller(imageId, direction, sellerId) {
|
||||
return db.transaction(async (tx) => {
|
||||
// Lock the owning physical item first, serializing moves within a gallery.
|
||||
const [owned] = await tx
|
||||
.select({ inventoryItemId: inventoryItems.id })
|
||||
.from(inventoryImages)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryImages.id, imageId), eq(products.sellerId, sellerId)))
|
||||
.for('update', { of: inventoryItems });
|
||||
if (!owned) return null;
|
||||
|
||||
const images = await tx.select().from(inventoryImages)
|
||||
.where(eq(inventoryImages.inventoryItemId, owned.inventoryItemId))
|
||||
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id)
|
||||
.for('update');
|
||||
const target = images.find((image) => image.id === imageId);
|
||||
if (!target) return null;
|
||||
const reordered = moveImageInOrder(images, imageId, direction);
|
||||
if (reordered === images) return target;
|
||||
const updatedAt = new Date();
|
||||
for (const [sortOrder, image] of reordered.entries()) {
|
||||
if (image.sortOrder !== sortOrder) {
|
||||
await tx.update(inventoryImages).set({ sortOrder, updatedAt })
|
||||
.where(eq(inventoryImages.id, image.id));
|
||||
}
|
||||
}
|
||||
const [moved] = await tx.select().from(inventoryImages).where(eq(inventoryImages.id, imageId));
|
||||
return moved;
|
||||
});
|
||||
},
|
||||
|
||||
async updateOrderForSeller(imageId, sortOrder, sellerId) {
|
||||
const owned = await this.findByIdForSeller(imageId, sellerId);
|
||||
if (!owned) return null;
|
||||
@@ -135,7 +187,7 @@ export function createInventoryImageRepository(db) {
|
||||
eq(inventoryImages.processingStatus, 'COMPLETED'),
|
||||
eq(imageDerivativeJobs.status, 'COMPLETED'),
|
||||
))
|
||||
.orderBy(inventoryImages.sortOrder);
|
||||
.orderBy(inventoryImages.sortOrder, inventoryImages.createdAt, inventoryImages.id);
|
||||
},
|
||||
|
||||
async findPublicById(imageId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, exists, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
inventoryItems,
|
||||
@@ -35,6 +35,46 @@ export function createInventoryItemRepository(db) {
|
||||
.then((rows) => rows.map(({ item }) => item));
|
||||
},
|
||||
|
||||
async archiveByIdsForSeller(inventoryItemIds, sellerId) {
|
||||
if (inventoryItemIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.update(inventoryItems)
|
||||
.set({ status: 'ARCHIVED', updatedAt: new Date() })
|
||||
.where(and(
|
||||
inArray(inventoryItems.id, inventoryItemIds),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
exists(
|
||||
db
|
||||
.select({ one: sql`1` })
|
||||
.from(products)
|
||||
.where(and(
|
||||
eq(products.id, inventoryItems.productId),
|
||||
eq(products.sellerId, sellerId),
|
||||
)),
|
||||
),
|
||||
))
|
||||
.returning();
|
||||
},
|
||||
|
||||
async updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags) {
|
||||
const rows = await db
|
||||
.select({ productId: inventoryItems.productId })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(inArray(inventoryItems.id, inventoryItemIds), eq(products.sellerId, sellerId)));
|
||||
const productIds = [...new Set(rows.map(({ productId }) => productId))];
|
||||
const updated = [];
|
||||
for (const productId of productIds) {
|
||||
const [product] = await db.select().from(products).where(eq(products.id, productId));
|
||||
const tags = [...new Set([...(product.tags ?? []), ...addTags.map((tag) => tag.toLowerCase())])]
|
||||
.filter((tag) => !removeTags.map((value) => value.toLowerCase()).includes(tag));
|
||||
const [result] = await db.update(products).set({ tags, updatedAt: new Date() }).where(eq(products.id, productId)).returning();
|
||||
updated.push(result);
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
|
||||
async searchPublic({ page = 1, limit = 20 } = {}) {
|
||||
const conditions = [
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
||||
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image';
|
||||
import { createInventoryImageSchema, uploadInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
const data = await part.toBuffer();
|
||||
const fields = part.fields;
|
||||
const originalFilename = part.filename;
|
||||
const result = createInventoryImageSchema.safeParse({
|
||||
const result = uploadInventoryImageSchema.safeParse({
|
||||
storageKey: `inventory/${request.params.id}/${randomUUID()}`,
|
||||
originalFilename,
|
||||
mimeType: part.mimetype,
|
||||
fileSize: data.length,
|
||||
width: Number(fields.width?.value),
|
||||
height: Number(fields.height?.value),
|
||||
altText: fields.altText?.value,
|
||||
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
|
||||
isPrimary: fields.isPrimary?.value === 'true',
|
||||
@@ -98,11 +96,25 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
return admin;
|
||||
}
|
||||
|
||||
app.patch('/images/:id/order', async (request) => {
|
||||
app.post('/images/:id/move', async (request, reply) => {
|
||||
const admin = await authorizeAdminMutation(request);
|
||||
const result = moveInventoryImageSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const image = await inventoryImageService.move({
|
||||
imageId: request.params.id,
|
||||
direction: result.data.direction,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
});
|
||||
|
||||
app.patch('/images/:id/order', async (request, reply) => {
|
||||
const admin = await authorizeAdminMutation(request);
|
||||
const result = updateImageOrderSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const image = await inventoryImageService.updateOrder({
|
||||
imageId: request.params.id,
|
||||
sortOrder: request.body?.sortOrder,
|
||||
sortOrder: result.data.sortOrder,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
@@ -117,11 +129,13 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
});
|
||||
|
||||
app.patch('/images/:id/alt-text', async (request) => {
|
||||
app.patch('/images/:id/alt-text', async (request, reply) => {
|
||||
const admin = await authorizeAdminMutation(request);
|
||||
const result = updateImageAltTextSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
const image = await inventoryImageService.updateAltText({
|
||||
imageId: request.params.id,
|
||||
altText: request.body?.altText ?? null,
|
||||
altText: result.data.altText,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
return { data: toAdminInventoryImageDto(image) };
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
|
||||
import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory';
|
||||
import {
|
||||
bulkInventorySchema,
|
||||
createInventoryItemSchema,
|
||||
} from '@pokemon-inventory/validation/inventory';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
@@ -58,4 +61,44 @@ export async function registerAdminInventoryRoutes(app, options) {
|
||||
app.get('/inventory', async (request) => ({
|
||||
data: (await inventoryItemService.listBySellerId(request.admin.sellerId)).map(toAdminInventoryItemDto),
|
||||
}));
|
||||
|
||||
app.post('/bulk', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const result = bulkInventorySchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const inventoryItems = result.data.action === 'ARCHIVE'
|
||||
? await inventoryItemService.archiveMany({
|
||||
inventoryItemIds: result.data.inventoryItemIds,
|
||||
sellerId: request.admin.sellerId,
|
||||
})
|
||||
: result.data.action === 'PRICING_REFRESH'
|
||||
? await inventoryItemService.refreshPricingMany({
|
||||
inventoryItemIds: result.data.inventoryItemIds,
|
||||
providerId: result.data.providerId,
|
||||
sellerId: request.admin.sellerId,
|
||||
})
|
||||
: await inventoryItemService.updateTagsMany({
|
||||
inventoryItemIds: result.data.inventoryItemIds,
|
||||
sellerId: request.admin.sellerId,
|
||||
addTags: result.data.add ?? [],
|
||||
removeTags: result.data.remove ?? [],
|
||||
});
|
||||
|
||||
if (result.data.action === 'ARCHIVE') {
|
||||
return reply.send({ data: inventoryItems.map(toAdminInventoryItemDto) });
|
||||
}
|
||||
|
||||
if (result.data.action === 'TAGS') return reply.send({ data: inventoryItems });
|
||||
|
||||
return reply.code(202).send({
|
||||
data: inventoryItems.map(({ id, status, createdAt }) => ({ id, status, createdAt })),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { validateImageMove } from '../domain/image-order.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
import { validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
|
||||
import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
|
||||
|
||||
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage }) {
|
||||
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector, sellerSettingsRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
validateInventoryImageMetadata(input);
|
||||
const image = await inventoryImageRepository.createForSeller(
|
||||
const settingsRecord = await sellerSettingsRepository?.findBySellerId(input.sellerId);
|
||||
const maxImages = settingsRecord?.settings?.inventory?.maxImagesPerItem ?? 10;
|
||||
if (!Number.isInteger(maxImages) || maxImages < 1 || maxImages > 100) {
|
||||
throw createApplicationError('IMAGE_COUNT_LIMIT_INVALID', 'Image count limit is invalid.', 500);
|
||||
}
|
||||
const createImage = inventoryImageRepository.createForSellerWithLimit ?? inventoryImageRepository.createForSeller;
|
||||
const image = await createImage.call(inventoryImageRepository,
|
||||
{ ...input, processingStatus: 'PENDING' },
|
||||
input.sellerId,
|
||||
maxImages,
|
||||
);
|
||||
|
||||
if (!image) {
|
||||
@@ -29,8 +37,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
|
||||
async upload(input) {
|
||||
const { data, ...metadata } = input;
|
||||
validateInventoryImageMetadata(metadata);
|
||||
validateImageSignature(data, metadata.mimeType);
|
||||
// Bound work before decoding; client dimensions and byte counts are not authoritative.
|
||||
validateInventoryImageMetadata({ ...metadata, fileSize: data.length, width: 1, height: 1 });
|
||||
let inspected;
|
||||
try { inspected = await imageInspector.readMetadata(data); }
|
||||
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
|
||||
if (inspected.mimeType !== metadata.mimeType) {
|
||||
throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image format does not match the declared MIME type.', 400);
|
||||
}
|
||||
Object.assign(metadata, { width: inspected.width, height: inspected.height, fileSize: data.length });
|
||||
validateInventoryImageMetadata(metadata);
|
||||
try { await imageInspector.validateDecoding(data); }
|
||||
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
|
||||
|
||||
await imageStorage.put({ key: metadata.storageKey, data });
|
||||
|
||||
@@ -42,7 +61,13 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
}
|
||||
},
|
||||
|
||||
async move({ imageId, direction, sellerId }) {
|
||||
validateImageMove(direction);
|
||||
return requireOwnedImage(inventoryImageRepository.moveForSeller(imageId, direction, sellerId));
|
||||
},
|
||||
|
||||
async updateOrder({ imageId, sortOrder, sellerId }) {
|
||||
validateImageSortOrder(sortOrder);
|
||||
return requireOwnedImage(inventoryImageRepository.updateOrderForSeller(imageId, sortOrder, sellerId));
|
||||
},
|
||||
|
||||
@@ -51,7 +76,7 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
},
|
||||
|
||||
async updateAltText({ imageId, altText, sellerId }) {
|
||||
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, altText, sellerId));
|
||||
return requireOwnedImage(inventoryImageRepository.updateAltTextForSeller(imageId, normalizeImageAltText(altText), sellerId));
|
||||
},
|
||||
|
||||
async delete({ imageId, sellerId }) {
|
||||
@@ -81,4 +106,4 @@ async function requireOwnedImage(imagePromise) {
|
||||
const image = await imagePromise;
|
||||
if (!image) throw imageNotFoundError();
|
||||
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';
|
||||
|
||||
export function createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
pricingService,
|
||||
}) {
|
||||
return {
|
||||
async listBySellerId(sellerId) {
|
||||
@@ -35,5 +36,45 @@ export function createInventoryItemService({
|
||||
|
||||
return inventoryItemRepository.create(inventoryItem);
|
||||
},
|
||||
|
||||
async archiveMany({ inventoryItemIds, sellerId }) {
|
||||
const items = await inventoryItemRepository.listBySellerId(sellerId);
|
||||
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
|
||||
|
||||
if (requestedItems.length !== inventoryItemIds.length) {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
|
||||
}
|
||||
|
||||
requestedItems.forEach(({ status }) => archiveInventoryItem(status));
|
||||
return inventoryItemRepository.archiveByIdsForSeller(inventoryItemIds, sellerId);
|
||||
},
|
||||
|
||||
async refreshPricingMany({ inventoryItemIds, sellerId, providerId }) {
|
||||
if (!pricingService) {
|
||||
throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
|
||||
}
|
||||
|
||||
const items = await inventoryItemRepository.listBySellerId(sellerId);
|
||||
const requestedItems = items.filter(({ id }) => inventoryItemIds.includes(id));
|
||||
if (requestedItems.length !== inventoryItemIds.length) {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
|
||||
}
|
||||
|
||||
return Promise.all(requestedItems.map((item) => pricingService.enqueueRefresh({
|
||||
sellerId,
|
||||
inventoryItemId: item.id,
|
||||
providerId,
|
||||
productId: item.productId,
|
||||
conditionId: item.conditionId,
|
||||
})));
|
||||
},
|
||||
|
||||
async updateTagsMany({ inventoryItemIds, sellerId, addTags = [], removeTags = [] }) {
|
||||
const items = await inventoryItemRepository.listBySellerId(sellerId);
|
||||
if (items.filter(({ id }) => inventoryItemIds.includes(id)).length !== inventoryItemIds.length) {
|
||||
throw createApplicationError('INVENTORY_NOT_FOUND', 'One or more inventory items were not found.', 404);
|
||||
}
|
||||
return inventoryItemRepository.updateProductTagsByIdsForSeller(inventoryItemIds, sellerId, addTags, removeTags);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
marketValueOverrides,
|
||||
marketValuations,
|
||||
products,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
} from "../../../infrastructure/database/schema.js";
|
||||
|
||||
export function createListingRepository(db) {
|
||||
@@ -75,6 +77,11 @@ export function createListingRepository(db) {
|
||||
text = "",
|
||||
category,
|
||||
conditionId,
|
||||
setName,
|
||||
eventName,
|
||||
tag,
|
||||
status,
|
||||
newlyAdded = false,
|
||||
minPriceMinor,
|
||||
maxPriceMinor,
|
||||
page = 1,
|
||||
@@ -86,16 +93,26 @@ export function createListingRepository(db) {
|
||||
const conditions = [
|
||||
inArray(
|
||||
listings.status,
|
||||
includeSold
|
||||
? ["AVAILABLE", "PENDING", "CLOSED"]
|
||||
: ["AVAILABLE", "PENDING"],
|
||||
status === "SOLD"
|
||||
? ["CLOSED"]
|
||||
: status === "PENDING"
|
||||
? ["PENDING"]
|
||||
: status === "AVAILABLE"
|
||||
? ["AVAILABLE"]
|
||||
: includeSold
|
||||
? ["AVAILABLE", "PENDING", "CLOSED"]
|
||||
: ["AVAILABLE", "PENDING"],
|
||||
),
|
||||
inArray(
|
||||
inventoryItems.status,
|
||||
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) {
|
||||
conditions.push(eq(products.categoryId, category));
|
||||
}
|
||||
@@ -104,6 +121,18 @@ export function createListingRepository(db) {
|
||||
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") {
|
||||
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
|
||||
}
|
||||
@@ -125,6 +154,7 @@ export function createListingRepository(db) {
|
||||
ilike(products.description, `%${term}%`),
|
||||
ilike(products.setName, `%${term}%`),
|
||||
ilike(products.brand, `%${term}%`),
|
||||
sql`${products.tags}::text ILIKE ${`%${term}%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -133,8 +163,28 @@ export function createListingRepository(db) {
|
||||
}
|
||||
|
||||
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 =
|
||||
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
|
||||
: sort === "price_high"
|
||||
? desc(listings.askingPriceMinor)
|
||||
@@ -172,7 +222,12 @@ export function createListingRepository(db) {
|
||||
|
||||
async findPublicById(listingId) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, condition: conditions })
|
||||
.select({
|
||||
listing: listings,
|
||||
condition: conditions,
|
||||
sellerDisplayName: sellers.displayName,
|
||||
sellerSettings: sellerSettings.settings,
|
||||
})
|
||||
.from(listings)
|
||||
.innerJoin(
|
||||
inventoryItems,
|
||||
@@ -180,6 +235,8 @@ export function createListingRepository(db) {
|
||||
)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
|
||||
.innerJoin(sellers, eq(sellers.id, products.sellerId))
|
||||
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
|
||||
.where(
|
||||
and(
|
||||
eq(listings.id, listingId),
|
||||
@@ -214,6 +271,11 @@ export function createListingRepository(db) {
|
||||
conditionCode: result.condition.code,
|
||||
marketValueMinor: override?.valueMinor ?? valuation?.valueMinor ?? null,
|
||||
marketValueCurrency: override?.currency ?? valuation?.currency ?? null,
|
||||
marketValueUpdatedAt: override?.createdAt ?? valuation?.calculatedAt ?? null,
|
||||
sellerDisplayName: result.sellerDisplayName,
|
||||
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
|
||||
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
|
||||
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { createPricingProvider } from './pricing-provider.js';
|
||||
|
||||
export function assertProductionPricingProviderConfig({ fixtureEnabled, endpoint }) {
|
||||
if (fixtureEnabled) {
|
||||
throw new Error('The fixture pricing provider must be disabled in production.');
|
||||
}
|
||||
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
|
||||
throw new Error('A production pricing provider endpoint is required.');
|
||||
}
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error('The production pricing provider endpoint must be a valid HTTPS URL.');
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('The production pricing provider endpoint must use HTTPS.');
|
||||
}
|
||||
}
|
||||
|
||||
export function createHttpPricingProvider({
|
||||
id,
|
||||
endpoint,
|
||||
@@ -10,10 +28,21 @@ export function createHttpPricingProvider({
|
||||
throw new Error('Pricing provider endpoint is required.');
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(endpoint);
|
||||
} catch {
|
||||
throw new Error('Pricing provider endpoint must be a valid HTTPS URL.');
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('Pricing provider endpoint must use HTTPS.');
|
||||
}
|
||||
|
||||
return createPricingProvider({
|
||||
id,
|
||||
async observe(input) {
|
||||
const url = new URL(endpoint);
|
||||
const url = new URL(parsedUrl.toString());
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
@@ -164,12 +164,11 @@ export function createPricingRefreshJobRepository(db) {
|
||||
});
|
||||
},
|
||||
|
||||
async purgeCompletedBefore(sellerId, cutoff) {
|
||||
async purgeCompletedBefore(cutoff) {
|
||||
const deleted = await db
|
||||
.delete(pricingRefreshJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pricingRefreshJobs.sellerId, sellerId),
|
||||
eq(pricingRefreshJobs.status, "COMPLETED"),
|
||||
lte(pricingRefreshJobs.updatedAt, cutoff),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
toAdminMarketValuationDto,
|
||||
toAdminMarketValuationSummaryDto,
|
||||
toAdminPricingObservationDto,
|
||||
} from "@pokemon-inventory/contracts/pricing";
|
||||
import {
|
||||
@@ -91,6 +92,14 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
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) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
|
||||
@@ -203,6 +203,10 @@ export function createPricingService({
|
||||
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
|
||||
},
|
||||
|
||||
async listValuations({ sellerId }) {
|
||||
return pricingRepository.listLatestValuationsForSeller(sellerId);
|
||||
},
|
||||
|
||||
async retryJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(
|
||||
jobId,
|
||||
|
||||
@@ -6,11 +6,13 @@ function requiredText(value, fieldName) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createProduct({ name, categoryId, sellerId }) {
|
||||
export function createProduct({ name, categoryId, sellerId, tags = [] }) {
|
||||
const normalizedTags = [...new Set(tags.map((tag) => tag.trim().toLowerCase()))];
|
||||
return {
|
||||
name: requiredText(name, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
tags: normalizedTags,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createProductRepository(db) {
|
||||
@@ -24,10 +26,16 @@ export function createProductRepository(db) {
|
||||
|
||||
async findPublicBySlug(slug) {
|
||||
const [result] = await db
|
||||
.select({ product: products })
|
||||
.select({
|
||||
product: products,
|
||||
sellerDisplayName: sellers.displayName,
|
||||
sellerSettings: sellerSettings.settings,
|
||||
})
|
||||
.from(products)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.productId, products.id))
|
||||
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
|
||||
.innerJoin(sellers, eq(sellers.id, products.sellerId))
|
||||
.leftJoin(sellerSettings, eq(sellerSettings.sellerId, sellers.id))
|
||||
.where(and(
|
||||
eq(products.slug, slug),
|
||||
eq(inventoryItems.status, 'ACTIVE'),
|
||||
@@ -35,7 +43,14 @@ export function createProductRepository(db) {
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return result?.product ?? null;
|
||||
if (!result) return null;
|
||||
return {
|
||||
...result.product,
|
||||
sellerDisplayName: result.sellerDisplayName,
|
||||
sellerDescription: result.sellerSettings?.general?.publicDescription || null,
|
||||
sellerContactEmail: result.sellerSettings?.general?.publicContactEmail || null,
|
||||
sellerContactPhone: result.sellerSettings?.general?.publicContactPhone || null,
|
||||
};
|
||||
},
|
||||
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
|
||||
@@ -19,6 +19,11 @@ export function createSearchQuery({
|
||||
query = "",
|
||||
category,
|
||||
conditionId,
|
||||
setName,
|
||||
eventName,
|
||||
tag,
|
||||
status,
|
||||
newlyAdded = false,
|
||||
minPriceMinor,
|
||||
maxPriceMinor,
|
||||
page = 1,
|
||||
@@ -36,6 +41,10 @@ export function createSearchQuery({
|
||||
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 (
|
||||
typeof minPriceMinor !== "undefined" &&
|
||||
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
||||
@@ -58,8 +67,8 @@ export function createSearchQuery({
|
||||
throw new Error("minPriceMinor must be <= maxPriceMinor");
|
||||
}
|
||||
|
||||
if (!["recent", "price_low", "price_high"].includes(sort)) {
|
||||
throw new Error("sort must be recent, price_low, or price_high");
|
||||
if (!["recent", "updated", "name_asc", "name_desc", "price_low", "price_high", "market_low", "market_high"].includes(sort)) {
|
||||
throw new Error("sort must be recent, updated, name_asc, name_desc, price_low, price_high, market_low, or market_high");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -70,11 +79,22 @@ export function createSearchQuery({
|
||||
...(typeof conditionId === "string" && conditionId.trim().length > 0
|
||||
? { conditionId: conditionId.trim() }
|
||||
: {}),
|
||||
...(typeof setName === "string" && setName.trim().length > 0
|
||||
? { setName: setName.trim() }
|
||||
: {}),
|
||||
...(typeof eventName === "string" && eventName.trim().length > 0
|
||||
? { eventName: eventName.trim() }
|
||||
: {}),
|
||||
...(typeof tag === "string" && tag.trim().length > 0
|
||||
? { tag: tag.trim().toLowerCase() }
|
||||
: {}),
|
||||
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
|
||||
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
|
||||
page,
|
||||
limit,
|
||||
includeSold,
|
||||
...(status ? { status } : {}),
|
||||
...(newlyAdded ? { newlyAdded: true } : {}),
|
||||
...(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,
|
||||
category: request.query.category,
|
||||
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(
|
||||
request.query.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 <note>/);
|
||||
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 === 'storage-delete'));
|
||||
});
|
||||
|
||||
test('moves an image through the owned atomic repository operation', async () => {
|
||||
const service = createInventoryImageService({ inventoryImageRepository: {
|
||||
async moveForSeller(id, direction, sellerId) {
|
||||
assert.equal(direction, 'down');
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return id === 'owned' ? { id, sortOrder: 1 } : null;
|
||||
},
|
||||
} });
|
||||
assert.deepEqual(await service.move({ imageId: 'owned', direction: 'down', sellerId: 'seller-1' }), { id: 'owned', sortOrder: 1 });
|
||||
await assert.rejects(() => service.move({ imageId: 'missing', direction: 'down', sellerId: 'seller-1' }), { code: 'IMAGE_NOT_FOUND' });
|
||||
await assert.rejects(() => service.move({ imageId: 'owned', direction: 'left', sellerId: 'seller-1' }), { code: 'IMAGE_MOVE_INVALID' });
|
||||
});
|
||||
|
||||
test('validates image updates before persistence and normalizes valid alt text', async () => {
|
||||
const { service, calls } = createService();
|
||||
for (const sortOrder of [undefined, null, -1, 0.5, '1', NaN, Infinity, 2147483648]) {
|
||||
await assert.rejects(() => service.updateOrder({ imageId: 'image-1', sortOrder, sellerId: 'seller-1' }), { code: 'IMAGE_ORDER_INVALID' });
|
||||
}
|
||||
for (const altText of [undefined, 123, {}, [], 'x'.repeat(1001)]) {
|
||||
await assert.rejects(() => service.updateAltText({ imageId: 'image-1', altText, sellerId: 'seller-1' }), { code: 'IMAGE_ALT_TEXT_INVALID' });
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
await service.updateOrder({ imageId: 'image-1', sortOrder: 2147483647, sellerId: 'seller-1' });
|
||||
for (const [input, expected] of [[' Front ', 'Front'], [' ', ''], [null, null], ['x'.repeat(1000), 'x'.repeat(1000)]]) {
|
||||
const result = await service.updateAltText({ imageId: 'image-1', altText: input, sellerId: 'seller-1' });
|
||||
assert.equal(result.altText, expected);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
|
||||
const stored = [];
|
||||
const jobs = [];
|
||||
const service = createInventoryImageService({
|
||||
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
|
||||
imageStorage: {
|
||||
async put(value) { stored.push(value); },
|
||||
async delete() {},
|
||||
@@ -38,6 +39,9 @@ test('stores validated upload bytes before persisting image metadata', async ()
|
||||
assert.equal(stored.length, 1);
|
||||
assert.deepEqual(stored[0], { key: input.storageKey, data: input.data });
|
||||
assert.equal(image.id, 'image-1');
|
||||
assert.equal(image.width, 2);
|
||||
assert.equal(image.height, 3);
|
||||
assert.equal(image.fileSize, input.data.length);
|
||||
assert.equal(image.processingStatus, 'PENDING');
|
||||
assert.equal('data' in image, false);
|
||||
assert.deepEqual(jobs, [{
|
||||
@@ -50,6 +54,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
|
||||
test('removes stored bytes when metadata persistence fails', async () => {
|
||||
const deleted = [];
|
||||
const service = createInventoryImageService({
|
||||
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
|
||||
imageStorage: {
|
||||
async put() {},
|
||||
async delete(key) { deleted.push(key); },
|
||||
@@ -64,4 +69,21 @@ test('removes stored bytes when metadata persistence fails', async () => {
|
||||
(error) => error.code === 'INVENTORY_ITEM_NOT_FOUND',
|
||||
);
|
||||
assert.deepEqual(deleted, [input.storageKey]);
|
||||
});
|
||||
});
|
||||
|
||||
test('passes the seller image limit to the atomic repository operation', async () => {
|
||||
let limit;
|
||||
const service = createInventoryImageService({
|
||||
sellerSettingsRepository: { async findBySellerId() { return { settings: { inventory: { maxImagesPerItem: 2 } } }; } },
|
||||
inventoryImageRepository: {
|
||||
async createForSellerWithLimit(image, sellerId, maxImages) {
|
||||
assert.equal(sellerId, 'seller-id');
|
||||
limit = maxImages;
|
||||
return { id: 'image-1', ...image };
|
||||
},
|
||||
},
|
||||
});
|
||||
const image = await service.create({ ...input, data: undefined });
|
||||
assert.equal(image.id, 'image-1');
|
||||
assert.equal(limit, 2);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,13 @@ function createService({ product, condition }) {
|
||||
return createInventoryItemService({
|
||||
productRepository: { async findById() { return product; } },
|
||||
conditionRepository: { async findById() { return condition; } },
|
||||
inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } },
|
||||
inventoryItemRepository: {
|
||||
async create(inventoryItem) { return inventoryItem; },
|
||||
async listBySellerId() { return []; },
|
||||
async archiveByIdsForSeller(inventoryItemIds) {
|
||||
return inventoryItemIds.map((id) => ({ id, status: 'ARCHIVED' }));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +37,110 @@ test('creates an inventory item when its condition belongs to the product catego
|
||||
});
|
||||
});
|
||||
|
||||
test('archives owned active inventory items in bulk', async () => {
|
||||
const service = createInventoryItemService({
|
||||
productRepository: { async findById() { return null; } },
|
||||
conditionRepository: { async findById() { return null; } },
|
||||
inventoryItemRepository: {
|
||||
async listBySellerId() {
|
||||
return [
|
||||
{ id: 'item-1', status: 'ACTIVE' },
|
||||
{ id: 'item-2', status: 'ACTIVE' },
|
||||
];
|
||||
},
|
||||
async archiveByIdsForSeller(ids) {
|
||||
return ids.map((id) => ({ id, status: 'ARCHIVED' }));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await service.archiveMany({ inventoryItemIds: ['item-1', 'item-2'], sellerId: 'seller-id' }),
|
||||
[
|
||||
{ id: 'item-1', status: 'ARCHIVED' },
|
||||
{ id: 'item-2', status: 'ARCHIVED' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('does not archive sold inventory items', async () => {
|
||||
const service = createInventoryItemService({
|
||||
productRepository: { async findById() { return null; } },
|
||||
conditionRepository: { async findById() { return null; } },
|
||||
inventoryItemRepository: {
|
||||
async listBySellerId() { return [{ id: 'item-1', status: 'SOLD' }]; },
|
||||
async archiveByIdsForSeller() { throw new Error('should not update'); },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.archiveMany({ inventoryItemIds: ['item-1'], sellerId: 'seller-id' }),
|
||||
/Inventory status cannot transition from SOLD to ARCHIVED/,
|
||||
);
|
||||
});
|
||||
|
||||
test('enqueues pricing refresh jobs for owned inventory items', async () => {
|
||||
const enqueued = [];
|
||||
const service = createInventoryItemService({
|
||||
productRepository: { async findById() { return null; } },
|
||||
conditionRepository: { async findById() { return null; } },
|
||||
inventoryItemRepository: {
|
||||
async listBySellerId() {
|
||||
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE' }];
|
||||
},
|
||||
},
|
||||
pricingService: {
|
||||
async enqueueRefresh(input) {
|
||||
enqueued.push(input);
|
||||
return { id: 'job-1', status: 'PENDING' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jobs = await service.refreshPricingMany({
|
||||
inventoryItemIds: ['item-1'],
|
||||
sellerId: 'seller-id',
|
||||
providerId: 'fixture-provider',
|
||||
});
|
||||
|
||||
assert.deepEqual(enqueued, [{
|
||||
sellerId: 'seller-id',
|
||||
inventoryItemId: 'item-1',
|
||||
providerId: 'fixture-provider',
|
||||
productId: 'product-1',
|
||||
conditionId: 'condition-1',
|
||||
}]);
|
||||
assert.deepEqual(jobs, [{ id: 'job-1', status: 'PENDING' }]);
|
||||
});
|
||||
|
||||
test('updates Product tags for selected owned inventory items', async () => {
|
||||
const updates = [];
|
||||
const service = createInventoryItemService({
|
||||
productRepository: { async findById() { return null; } },
|
||||
conditionRepository: { async findById() { return null; } },
|
||||
inventoryItemRepository: {
|
||||
async listBySellerId() {
|
||||
return [
|
||||
{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1' },
|
||||
{ id: 'item-2', productId: 'product-1', conditionId: 'condition-1' },
|
||||
];
|
||||
},
|
||||
async updateProductTagsByIdsForSeller(...input) {
|
||||
updates.push(input);
|
||||
return [{ id: 'product-1', tags: ['binder', 'keep'] }];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.updateTagsMany({
|
||||
inventoryItemIds: ['item-1', 'item-2'],
|
||||
sellerId: 'seller-id',
|
||||
addTags: ['Binder'],
|
||||
removeTags: ['sell'],
|
||||
}), [{ id: 'product-1', tags: ['binder', 'keep'] }]);
|
||||
assert.deepEqual(updates, [[['item-1', 'item-2'], 'seller-id', ['Binder'], ['sell']]]);
|
||||
});
|
||||
|
||||
test('rejects conditions from another category', async () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
createPricingProviderRegistry,
|
||||
} from "../../src/modules/pricing/providers/pricing-provider.js";
|
||||
import { createFixturePricingProvider } from "../../src/modules/pricing/providers/fixture-pricing-provider.js";
|
||||
import { createHttpPricingProvider } from "../../src/modules/pricing/providers/http-pricing-provider.js";
|
||||
import {
|
||||
assertProductionPricingProviderConfig,
|
||||
createHttpPricingProvider,
|
||||
} from "../../src/modules/pricing/providers/http-pricing-provider.js";
|
||||
|
||||
test("registers pricing providers behind a validated adapter contract", async () => {
|
||||
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 () => {
|
||||
let request;
|
||||
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, '');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { moveImageInOrder } from '../../src/modules/inventory/domain/image-order.js';
|
||||
|
||||
test('moves one image next to its neighbor without changing the input order', () => {
|
||||
const images = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
assert.deepEqual(moveImageInOrder(images, 'b', 'up').map(({ id }) => id), ['b', 'a', 'c']);
|
||||
assert.deepEqual(moveImageInOrder(images, 'b', 'down').map(({ id }) => id), ['a', 'c', 'b']);
|
||||
assert.deepEqual(images.map(({ id }) => id), ['a', 'b', 'c']);
|
||||
assert.equal(moveImageInOrder(images, 'a', 'up'), images);
|
||||
assert.equal(moveImageInOrder(images, 'c', 'down'), images);
|
||||
assert.throws(() => moveImageInOrder(images, 'a', 'left'), { code: 'IMAGE_MOVE_INVALID' });
|
||||
});
|
||||
@@ -14,6 +14,7 @@ test('creates a product with required product identity fields', () => {
|
||||
name: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
tags: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ test("normalizes a public search query and keeps filter values within bounds", (
|
||||
const query = createSearchQuery({
|
||||
query: " Charizard ex ",
|
||||
category: "Cards",
|
||||
setName: "SV151",
|
||||
eventName: "Worlds",
|
||||
tag: "Binder",
|
||||
minPriceMinor: 1000,
|
||||
maxPriceMinor: 5000,
|
||||
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.category, "Cards");
|
||||
assert.equal(query.setName, "SV151");
|
||||
assert.equal(query.eventName, "Worlds");
|
||||
assert.equal(query.tag, "binder");
|
||||
assert.equal(query.minPriceMinor, 1000);
|
||||
assert.equal(query.maxPriceMinor, 5000);
|
||||
assert.equal(query.page, 1);
|
||||
@@ -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", () => {
|
||||
assert.equal(createSearchQuery({ sort: "price_low" }).sort, "price_low");
|
||||
assert.equal(createSearchQuery({ sort: "updated" }).sort, "updated");
|
||||
assert.equal(createSearchQuery({ sort: "name_asc" }).sort, "name_asc");
|
||||
assert.equal(createSearchQuery({ sort: "name_desc" }).sort, "name_desc");
|
||||
assert.equal(createSearchQuery({ sort: "market_low" }).sort, "market_low");
|
||||
assert.equal(createSearchQuery({ sort: "market_high" }).sort, "market_high");
|
||||
assert.throws(
|
||||
() => createSearchQuery({ sort: "random" }),
|
||||
/sort must be recent/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts public status and newly-added filters", () => {
|
||||
const query = createSearchQuery({ status: "SOLD", newlyAdded: true });
|
||||
assert.equal(query.status, "SOLD");
|
||||
assert.equal(query.newlyAdded, true);
|
||||
assert.throws(() => createSearchQuery({ status: "UNKNOWN" }), /status must be/);
|
||||
});
|
||||
|
||||
test("normalizes whitespace and punctuation in free-text search terms", () => {
|
||||
assert.equal(
|
||||
normalizeSearchQuery(" Pikachu #199 / holo "),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createAdminExportService } from '../../src/modules/exports/services/admin-export-service.js';
|
||||
|
||||
test('downloads seller-scoped inventory JSON for an authenticated admin', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
adminExportService: createAdminExportService({
|
||||
inventoryItemService: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [{
|
||||
id: 'item-1',
|
||||
productId: 'product-1',
|
||||
conditionId: 'condition-1',
|
||||
status: 'ACTIVE',
|
||||
notes: 'Private note',
|
||||
}];
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/exports/inventory.json',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.json"');
|
||||
assert.deepEqual(JSON.parse(response.body), [{
|
||||
id: 'item-1',
|
||||
productId: 'product-1',
|
||||
conditionId: 'condition-1',
|
||||
status: 'ACTIVE',
|
||||
notes: 'Private note',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('requires authentication for inventory JSON exports', async (testContext) => {
|
||||
const app = buildApp({
|
||||
adminExportService: createAdminExportService({
|
||||
inventoryItemService: { async listBySellerId() { return []; } },
|
||||
}),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/exports/inventory.json',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 401);
|
||||
assert.equal(response.json().error.code, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
test('downloads seller-scoped inventory CSV for an authenticated admin', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
adminExportService: {
|
||||
async inventoryCsv({ sellerId }) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return 'id,status\r\n"item-1","ACTIVE"';
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/exports/inventory.csv',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'text/csv; charset=utf-8');
|
||||
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
|
||||
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
|
||||
});
|
||||
|
||||
test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
adminExportService: {
|
||||
async inventoryExcel({ sellerId }) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return '<?xml version="1.0"?><Workbook />';
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' });
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8');
|
||||
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"');
|
||||
assert.equal(response.body, '<?xml version="1.0"?><Workbook />');
|
||||
});
|
||||
|
||||
test('enqueues and polls an asynchronous inventory export', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
adminExportService: {
|
||||
async enqueueInventoryJson({ sellerId }) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return { id: 'job-1', status: 'PENDING', createdAt: '2026-09-10' };
|
||||
},
|
||||
async getExportJob({ sellerId, jobId }) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
assert.equal(jobId, 'job-1');
|
||||
return { id: jobId, format: 'JSON', status: 'COMPLETED', output: [{ id: 'item-1' }] };
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const enqueue = await app.inject({ method: 'POST', url: '/api/v1/admin/exports/inventory.json/jobs' });
|
||||
const poll = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1' });
|
||||
|
||||
assert.equal(enqueue.statusCode, 202);
|
||||
assert.equal(enqueue.json().data.id, 'job-1');
|
||||
assert.equal(poll.statusCode, 200);
|
||||
assert.deepEqual(poll.json().data.output, [{ id: 'item-1' }]);
|
||||
|
||||
const download = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/jobs/job-1/download' });
|
||||
assert.equal(download.statusCode, 200);
|
||||
assert.equal(download.headers['content-disposition'], 'attachment; filename="inventory.json"');
|
||||
assert.deepEqual(JSON.parse(download.body), [{ id: 'item-1' }]);
|
||||
});
|
||||
@@ -5,8 +5,9 @@ import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
|
||||
import { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, pricingService } = {}) {
|
||||
const products = [];
|
||||
const inventoryItems = [];
|
||||
const productRepository = {
|
||||
async create(product) {
|
||||
const createdProduct = {
|
||||
@@ -49,15 +50,30 @@ function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
},
|
||||
inventoryItemRepository: {
|
||||
async create(inventoryItem) {
|
||||
return {
|
||||
id: 'inventory-1',
|
||||
const createdItem = {
|
||||
id: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
|
||||
...inventoryItem,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
inventoryItems.push(createdItem);
|
||||
return createdItem;
|
||||
},
|
||||
async listBySellerId() {
|
||||
return inventoryItems;
|
||||
},
|
||||
async archiveByIdsForSeller(ids) {
|
||||
return inventoryItems.filter(({ id }) => ids.includes(id)).map((item) => {
|
||||
item.status = 'ARCHIVED';
|
||||
return item;
|
||||
});
|
||||
},
|
||||
async updateProductTagsByIdsForSeller() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
pricingService,
|
||||
}),
|
||||
productService: createProductService({ productRepository }),
|
||||
});
|
||||
@@ -146,6 +162,133 @@ test('creates inventory only for the authenticated sellers product', async (test
|
||||
assert.equal(response.json().data.status, 'ACTIVE');
|
||||
});
|
||||
|
||||
test('archives selected inventory through the authenticated bulk route', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const productResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory',
|
||||
payload: {
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/bulk',
|
||||
payload: {
|
||||
action: 'ARCHIVE',
|
||||
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data[0].status, 'ARCHIVED');
|
||||
});
|
||||
|
||||
test('enqueues selected inventory pricing refreshes through the bulk route', async (testContext) => {
|
||||
const enqueued = [];
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
pricingService: {
|
||||
async enqueueRefresh(input) {
|
||||
enqueued.push(input);
|
||||
return {
|
||||
id: 'job-1',
|
||||
status: 'PENDING',
|
||||
createdAt: new Date('2026-09-10T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const productResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory',
|
||||
payload: {
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/bulk',
|
||||
payload: {
|
||||
action: 'PRICING_REFRESH',
|
||||
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
|
||||
providerId: 'fixture-provider',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.deepEqual(enqueued, [{
|
||||
sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f',
|
||||
inventoryItemId: '8a0a836a-a597-454d-8c5e-e146d27dbbf2',
|
||||
providerId: 'fixture-provider',
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
}]);
|
||||
assert.equal(response.json().data[0].status, 'PENDING');
|
||||
});
|
||||
|
||||
test('updates Product tags through the authenticated bulk route', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const productResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
tags: ['Keep'],
|
||||
},
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory',
|
||||
payload: {
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/bulk',
|
||||
payload: {
|
||||
action: 'TAGS',
|
||||
inventoryItemIds: ['8a0a836a-a597-454d-8c5e-e146d27dbbf2'],
|
||||
add: ['Binder'],
|
||||
remove: ['sell'],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json().data, []);
|
||||
});
|
||||
|
||||
test('rejects an authenticated product write without a CSRF token', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
function createTestApp({ authorizeUnsafeRequest } = {}) {
|
||||
const views = [];
|
||||
return buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest,
|
||||
savedViewService: {
|
||||
async list() { return views; },
|
||||
async create({ view }) { const result = { id: 'view-1', ...view }; views.push(result); return result; },
|
||||
async update({ view }) { return { id: 'view-1', ...view }; },
|
||||
async delete() { return { id: 'view-1' }; },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('creates, lists, updates, and deletes saved views', async (testContext) => {
|
||||
const app = createTestApp();
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const createResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Needs Attention', filters: { missingImages: true } } });
|
||||
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/saved-views' });
|
||||
const updateResponse = await app.inject({ method: 'PATCH', url: '/api/v1/admin/saved-views/view-1', payload: { name: 'Recent', filters: { sort: 'recent' } } });
|
||||
const deleteResponse = await app.inject({ method: 'DELETE', url: '/api/v1/admin/saved-views/view-1' });
|
||||
|
||||
assert.equal(createResponse.statusCode, 201);
|
||||
assert.equal(listResponse.statusCode, 200);
|
||||
assert.equal(updateResponse.statusCode, 200);
|
||||
assert.equal(deleteResponse.statusCode, 204);
|
||||
});
|
||||
|
||||
test('protects saved-view mutations with CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Recent', filters: {} } });
|
||||
assert.equal(response.statusCode, 403);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
function createTestApp({ authorizeUnsafeRequest } = {}) {
|
||||
let settings = { pricing: { defaultStrategy: 'median' } };
|
||||
return buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest,
|
||||
sellerSettingsService: createSellerSettingsService({ sellerSettingsRepository: {
|
||||
async findBySellerId(sellerId) { assert.equal(sellerId, 'seller-1'); return { settings }; },
|
||||
async upsert(sellerId, nextSettings) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
settings = nextSettings;
|
||||
return { settings };
|
||||
},
|
||||
} }),
|
||||
});
|
||||
}
|
||||
|
||||
test('reads and updates seller settings', async (testContext) => {
|
||||
const app = createTestApp();
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const readResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/admin/settings',
|
||||
payload: { inventory: { maxImagesPerItem: 12 } },
|
||||
});
|
||||
|
||||
assert.equal(readResponse.statusCode, 200);
|
||||
assert.deepEqual(readResponse.json().data, { pricing: { defaultStrategy: 'median' } });
|
||||
assert.equal(updateResponse.statusCode, 200);
|
||||
assert.deepEqual(updateResponse.json().data, {
|
||||
pricing: { defaultStrategy: 'median' },
|
||||
inventory: { maxImagesPerItem: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
test('protects settings updates with CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/admin/settings',
|
||||
payload: { pricing: { defaultStrategy: 'median' } },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
|
||||
test('clears public contacts explicitly while omitted fields retain their values', async (testContext) => {
|
||||
const app = createTestApp();
|
||||
testContext.after(() => app.close());
|
||||
const patch = (general) => app.inject({ method: 'PATCH', url: '/api/v1/admin/settings', payload: { general } });
|
||||
assert.equal((await patch({ publicContactEmail: ' owner@example.test ', publicContactPhone: '555-0100', publicDescription: 'Collector.' })).statusCode, 200);
|
||||
const clearedEmail = await patch({ publicContactEmail: ' ' });
|
||||
assert.equal(clearedEmail.statusCode, 200);
|
||||
assert.deepEqual(clearedEmail.json().data.general, { publicContactEmail: '', publicContactPhone: '555-0100', publicDescription: 'Collector.' });
|
||||
assert.equal((await patch({ publicContactPhone: '' })).statusCode, 200);
|
||||
const read = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
|
||||
assert.deepEqual(read.json().data.general, { publicContactEmail: '', publicContactPhone: '', publicDescription: 'Collector.' });
|
||||
for (const general of [{ publicContactEmail: 'invalid' }, { publicContactEmail: null }, { publicContactPhone: 'x'.repeat(51) }]) {
|
||||
assert.equal((await patch(general)).statusCode, 400);
|
||||
}
|
||||
});
|
||||
@@ -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(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',
|
||||
askingPriceMinor: 2499,
|
||||
status: 'PENDING',
|
||||
sellerDisplayName: 'Pika Collectibles',
|
||||
sellerDescription: 'Independent Pokémon collector.',
|
||||
sellerContactEmail: 'hello@pika.example',
|
||||
sellerContactPhone: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -72,6 +76,9 @@ test('returns a public listing detail or a not-found response', async (testConte
|
||||
|
||||
assert.equal(detailResponse.statusCode, 200);
|
||||
assert.equal('inventoryItemId' in detailResponse.json().data, false);
|
||||
assert.equal(detailResponse.json().data.sellerDisplayName, 'Pika Collectibles');
|
||||
assert.equal(detailResponse.json().data.sellerDescription, 'Independent Pokémon collector.');
|
||||
assert.equal(detailResponse.json().data.sellerContactEmail, 'hello@pika.example');
|
||||
assert.equal(missingResponse.statusCode, 404);
|
||||
assert.equal(missingResponse.json().error.code, 'LISTING_NOT_FOUND');
|
||||
});
|
||||
@@ -21,6 +21,10 @@ function createTestApp() {
|
||||
name: 'Pikachu Plush',
|
||||
slug,
|
||||
description: 'A friendly plush.',
|
||||
sellerDisplayName: 'Pika Collectibles',
|
||||
sellerDescription: 'Independent Pokémon collector.',
|
||||
sellerContactEmail: 'hello@pika.example',
|
||||
sellerContactPhone: null,
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
@@ -50,6 +54,11 @@ test('returns a public product without private seller identity', async (testCont
|
||||
cardNumber: null,
|
||||
eventName: null,
|
||||
releaseYear: null,
|
||||
tags: [],
|
||||
sellerDisplayName: 'Pika Collectibles',
|
||||
sellerDescription: 'Independent Pokémon collector.',
|
||||
sellerContactEmail: 'hello@pika.example',
|
||||
sellerContactPhone: null,
|
||||
createdAt: '2026-09-07T00:00:00.000Z',
|
||||
updatedAt: '2026-09-07T00:00:00.000Z',
|
||||
});
|
||||
|
||||
@@ -12,6 +12,11 @@ test("returns public search results with pagination metadata", async (testContex
|
||||
assert.equal(filters.includeSold, true);
|
||||
assert.equal(filters.sort, "price_low");
|
||||
assert.equal(filters.conditionId, "condition-1");
|
||||
assert.equal(filters.setName, "SV151");
|
||||
assert.equal(filters.eventName, "Worlds");
|
||||
assert.equal(filters.tag, "binder");
|
||||
assert.equal(filters.status, "SOLD");
|
||||
assert.equal(filters.newlyAdded, true);
|
||||
return {
|
||||
items: [{ id: "listing-1", status: "CLOSED" }],
|
||||
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({
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
|
||||
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
products,
|
||||
securityAuditEvents,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
@@ -190,6 +193,23 @@ test('finds only products with active inventory and a public listing', async ()
|
||||
product.id,
|
||||
);
|
||||
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
|
||||
await database.db.insert(sellerSettings).values({ sellerId: seller.id, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
|
||||
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
|
||||
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, 'owner@example.test');
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
|
||||
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactEmail: '' } } });
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactEmail, null);
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, '555-0100');
|
||||
await settingsService.update({ sellerId: seller.id, settings: { general: { publicContactPhone: ' ' } } });
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerContactPhone, null);
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, 'Collector.');
|
||||
|
||||
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
|
||||
assert.equal((await productRepository.findPublicBySlug(product.slug)).sellerDescription, null);
|
||||
|
||||
await database.db
|
||||
.update(listings)
|
||||
.set({ status: 'CLOSED' })
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
|
||||
import { createSellerSettingsRepository } from '../../src/modules/settings/repositories/seller-settings-repository.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
sellerSettings,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js';
|
||||
@@ -148,6 +151,23 @@ test('searches public listings with pagination and hides sold items by default',
|
||||
visible.items[0].id,
|
||||
);
|
||||
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
|
||||
await database.db.insert(sellerSettings).values({ sellerId: product.sellerId, settings: { general: { publicDescription: 'Collector.' }, security: { sessionIdleMinutes: 60 } } });
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
|
||||
const settingsService = createSellerSettingsService({ sellerSettingsRepository: createSellerSettingsRepository(database.db) });
|
||||
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: 'owner@example.test', publicContactPhone: '555-0100' } } });
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, 'owner@example.test');
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
|
||||
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactEmail: '' } } });
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactEmail, null);
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, '555-0100');
|
||||
await settingsService.update({ sellerId: product.sellerId, settings: { general: { publicContactPhone: ' ' } } });
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerContactPhone, null);
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, 'Collector.');
|
||||
|
||||
await database.db.update(sellerSettings).set({ settings: { general: { publicDescription: '' } } });
|
||||
assert.equal((await listingRepository.findPublicById(visible.items[0].id)).sellerDescription, null);
|
||||
|
||||
await database.db
|
||||
.update(listings)
|
||||
.set({ status: 'CLOSED' })
|
||||
@@ -224,6 +244,17 @@ test('persists image metadata only for the owning seller and counts item images'
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces per-item image limits across concurrent inserts', async () => {
|
||||
const { seller, inventoryItem } = await createInventoryFixture();
|
||||
const image = (suffix) => ({ inventoryItemId: inventoryItem.id, storageKey: `inventory/${suffix}.jpg`, originalFilename: `${suffix}.jpg`, mimeType: 'image/jpeg', fileSize: 4, width: 2, height: 2, sortOrder: 0, isPrimary: false, processingStatus: 'PENDING' });
|
||||
const results = await Promise.allSettled([
|
||||
inventoryImageRepository.createForSellerWithLimit(image('a'), seller.id, 1),
|
||||
inventoryImageRepository.createForSellerWithLimit(image('b'), seller.id, 1),
|
||||
]);
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter((result) => result.status === 'rejected' && result.reason.code === 'IMAGE_COUNT_LIMIT_REACHED').length, 1);
|
||||
});
|
||||
|
||||
test('reads and updates listings only through the owning seller scope', async () => {
|
||||
const { seller, category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
@@ -253,3 +284,65 @@ test('reads and updates listings only through the owning seller scope', async ()
|
||||
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
|
||||
assert.equal(archivedListing.status, 'ARCHIVED');
|
||||
});
|
||||
|
||||
async function createImageOrderFixture() {
|
||||
const { seller, inventoryItem } = await createInventoryFixture();
|
||||
const images = [];
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
images.push(await inventoryImageRepository.createForSeller({
|
||||
inventoryItemId: inventoryItem.id, storageKey: `move-${index}.jpg`, originalFilename: `move-${index}.jpg`,
|
||||
mimeType: 'image/jpeg', fileSize: 100, width: 10, height: 10, sortOrder: 0,
|
||||
isPrimary: index === 0,
|
||||
}, seller.id));
|
||||
}
|
||||
return { seller, inventoryItem, images: await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id) };
|
||||
}
|
||||
|
||||
test('moves images atomically with tied positions, ownership checks and boundary no-ops', async () => {
|
||||
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||
const read = () => inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
|
||||
assert.equal(await inventoryImageRepository.moveForSeller(images[0].id, 'down', '00000000-0000-0000-0000-000000000000'), null);
|
||||
assert.equal(await inventoryImageRepository.moveForSeller('00000000-0000-0000-0000-000000000000', 'down', seller.id), null);
|
||||
assert.deepEqual(await read(), images);
|
||||
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
|
||||
assert.deepEqual(await read(), images);
|
||||
await inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id);
|
||||
const moved = await read();
|
||||
assert.deepEqual(moved.map(({ id }) => id), [images[1].id, images[0].id, images[2].id]);
|
||||
assert.deepEqual(moved.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
|
||||
assert.equal(moved.find(({ id }) => id === images[0].id).isPrimary, images[0].isPrimary);
|
||||
await inventoryImageRepository.moveForSeller(images[0].id, 'up', seller.id);
|
||||
assert.deepEqual((await read()).map(({ id }) => id), images.map(({ id }) => id));
|
||||
});
|
||||
|
||||
test('rolls back every position if an image move fails after its first write', async () => {
|
||||
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||
let writes = 0;
|
||||
const failingRepository = createInventoryImageRepository({
|
||||
transaction: (action) => database.db.transaction((tx) => action(new Proxy(tx, {
|
||||
get(target, property) {
|
||||
if (property === 'update') return (...args) => {
|
||||
writes += 1;
|
||||
if (writes === 2) throw new Error('Injected second write failure');
|
||||
return target.update(...args);
|
||||
};
|
||||
const value = target[property];
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
}))),
|
||||
});
|
||||
await assert.rejects(() => failingRepository.moveForSeller(images[0].id, 'down', seller.id), /Injected second write failure/);
|
||||
assert.equal(writes, 2);
|
||||
assert.deepEqual(await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id), images);
|
||||
});
|
||||
|
||||
test('serializes concurrent moves within the same gallery', async () => {
|
||||
const { seller, inventoryItem, images } = await createImageOrderFixture();
|
||||
await Promise.all([
|
||||
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
|
||||
inventoryImageRepository.moveForSeller(images[0].id, 'down', seller.id),
|
||||
]);
|
||||
const result = await inventoryImageRepository.listByInventoryItemForSeller(inventoryItem.id, seller.id);
|
||||
assert.deepEqual(result.map(({ id }) => id), [images[1].id, images[2].id, images[0].id]);
|
||||
assert.deepEqual(result.map(({ sortOrder }) => sortOrder), [0, 1, 2]);
|
||||
});
|
||||
|
||||
@@ -41,11 +41,12 @@ const pricingRefreshJobRepository = createPricingRefreshJobRepository(
|
||||
const providerMappingRepository = createProviderMappingRepository(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
|
||||
.insert(users)
|
||||
.values({
|
||||
email: "transactions@example.test",
|
||||
email: `${fixtureName}@example.test`,
|
||||
passwordHash: "not-a-real-password",
|
||||
})
|
||||
.returning();
|
||||
@@ -53,14 +54,14 @@ async function createFixture() {
|
||||
.insert(sellers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
displayName: "Transactions Owner",
|
||||
displayName: `${fixtureName} Owner`,
|
||||
})
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: "Transactions Cards",
|
||||
slug: "transactions-cards",
|
||||
name: `${fixtureName} Cards`,
|
||||
slug: `${fixtureName}-cards`,
|
||||
})
|
||||
.returning();
|
||||
const [profile] = await database.db
|
||||
@@ -84,8 +85,8 @@ async function createFixture() {
|
||||
.values({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: "Integration Card",
|
||||
slug: "integration-card",
|
||||
name: `${fixtureName} Card`,
|
||||
slug: `${fixtureName}-card`,
|
||||
})
|
||||
.returning();
|
||||
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");
|
||||
});
|
||||
|
||||
test("purges completed pricing jobs across sellers before the cutoff", async () => {
|
||||
const first = await createFixture();
|
||||
const second = await createFixture("second");
|
||||
const jobs = await Promise.all(
|
||||
[first, second].map(({ seller, product, condition, inventory }) =>
|
||||
pricingRefreshJobRepository.create({
|
||||
sellerId: seller.id,
|
||||
inventoryItemId: inventory[0].id,
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
providerId: "integration",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const job of jobs) {
|
||||
await pricingRefreshJobRepository.claimNext();
|
||||
await pricingRefreshJobRepository.complete(
|
||||
job.id,
|
||||
[{ priceMinor: 2500 }],
|
||||
{ valueMinor: 2500 },
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await pricingRefreshJobRepository.purgeCompletedBefore(
|
||||
new Date(Date.now() + 1000),
|
||||
);
|
||||
assert.equal(deleted, 2);
|
||||
assert.equal(
|
||||
(await pricingRefreshJobRepository.listForSeller(first.seller.id)).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(await pricingRefreshJobRepository.listForSeller(second.seller.id)).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("persists and disables seller-owned provider mappings", async () => {
|
||||
const { seller, product } = await createFixture();
|
||||
const created = await providerMappingRepository.createForSeller(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { setup } from '@storybook/vue3';
|
||||
|
||||
import '../src/assets/styles/global.scss';
|
||||
import { storybookRouter } from './router.js';
|
||||
|
||||
setup((app) => {
|
||||
app.use(storybookRouter);
|
||||
});
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
const stub = { render: () => null };
|
||||
|
||||
// Mirrors the named routes in src/router/index.js so RouterLink resolution
|
||||
// works in isolated stories without importing the real app router/guards.
|
||||
export const storybookRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'catalog', component: stub },
|
||||
{ path: '/listings/:id', name: 'listing-detail', component: stub },
|
||||
{ path: '/products/:slug', name: 'product-detail', component: stub },
|
||||
{ path: '/login', name: 'login', component: stub },
|
||||
{ path: '/admin', name: 'admin-home', component: stub },
|
||||
{ path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: stub },
|
||||
{ path: '/admin/inventory', name: 'admin-inventory', component: stub },
|
||||
{ path: '/admin/listings', name: 'admin-listings', component: stub },
|
||||
{ path: '/admin/sales', name: 'admin-sales', component: stub },
|
||||
{ path: '/admin/acquisitions', name: 'admin-acquisitions', component: stub },
|
||||
{ path: '/admin/settings', name: 'admin-settings', component: stub },
|
||||
{ path: '/admin/saved-views', name: 'admin-saved-views', component: stub },
|
||||
{ path: '/:pathMatch(.*)*', name: 'storybook-catch-all', component: stub },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Forces a narrow viewport for stories that specifically exercise mobile-only
|
||||
// CSS breakpoints, since @storybook/addon-viewport does not affect the
|
||||
// headless iframe size that @storybook/test-runner navigates to directly.
|
||||
export default {
|
||||
async preVisit(page, context) {
|
||||
if (context.id?.includes('--enlarged')) {
|
||||
await page.setViewportSize({ width: 640, height: 900 });
|
||||
} else if (context.id?.includes('--mobile')) {
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
--color-success-500: #12a150;
|
||||
--color-warning-500: #d88a00;
|
||||
--color-danger-500: #e5484d;
|
||||
--color-danger-solid: #b4232b;
|
||||
--color-info-500: #1683c4;
|
||||
--color-success-text: #08783a;
|
||||
--color-warning-text: #8a5700;
|
||||
@@ -91,6 +92,9 @@
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-success-text: #63d991;
|
||||
--color-warning-text: #ffc766;
|
||||
--color-danger-text: #ff9297;
|
||||
color-scheme: dark;
|
||||
--color-background: #0b0d10;
|
||||
--color-surface: #11161d;
|
||||
|
||||
@@ -9,6 +9,7 @@ button, input, select, textarea { font: inherit; }
|
||||
button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } }
|
||||
button { cursor: pointer; }
|
||||
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; border: 0; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
|
||||
.catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); }
|
||||
.catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; }
|
||||
@@ -23,7 +24,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
||||
.results-heading span { color: var(--color-text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-top: var(--space-6); }
|
||||
.detail-page { width: min(920px, calc(100% - var(--space-8))); margin: 0 auto; padding: var(--space-12) 0 var(--space-24); }
|
||||
.detail-back { color: var(--color-interactive); font-weight: var(--weight-bold); text-decoration: none; }
|
||||
.detail-back { color: var(--color-interactive-text); font-weight: var(--weight-bold); text-decoration: none; }
|
||||
.detail-content { max-width: 680px; padding-top: var(--space-16); }
|
||||
.detail-content h1 { font-size: clamp(3rem, 8vw, 6rem); }
|
||||
.detail-price { color: var(--color-text); font-size: var(--text-xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
|
||||
@@ -33,15 +34,18 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
||||
.product-facts dt { color: var(--color-text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.product-facts dd { margin: 0; }
|
||||
.catalog-state { padding: var(--space-12) 0; color: var(--color-text-muted); }
|
||||
.catalog-state--error { color: var(--color-danger-500); }
|
||||
.catalog-state--error { color: var(--color-danger-text); }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-6); margin-top: var(--space-8); color: var(--color-text-muted); font-size: var(--text-sm); }
|
||||
.pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive); }
|
||||
.pagination button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); background: var(--color-surface); color: var(--color-interactive-text); }
|
||||
.pagination button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
|
||||
.showcase-page { padding: var(--space-12) max(var(--space-4), calc((100vw - 1200px) / 2)); }
|
||||
.auth-page { display: grid; place-items: center; min-height: 100vh; padding: var(--space-8); background: var(--color-background); }
|
||||
.auth-panel { display: grid; gap: var(--space-3); width: min(440px, 100%); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-8); background: var(--color-surface); box-shadow: var(--shadow-md); }
|
||||
.auth-panel h1 { font-size: var(--text-2xl); }
|
||||
.skip-link { position: absolute; left: var(--space-4); top: var(--space-2); z-index: 10; transform: translateY(-200%); background: var(--color-surface); color: var(--color-interactive-text); padding: var(--space-2) var(--space-4); border-radius: var(--radius-sm); }
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; background: var(--color-background); }
|
||||
.admin-sidebar { display: grid; align-content: start; gap: var(--space-6); border-right: var(--border-thin) solid var(--color-border); padding: var(--space-8); background: var(--color-surface); }
|
||||
.admin-sidebar h1 { font-size: var(--text-xl); }
|
||||
@@ -53,6 +57,9 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
||||
.admin-page { max-width: 880px; }
|
||||
.admin-workspace-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-8) 0; }
|
||||
.admin-workspace-grid form { display: grid; gap: var(--space-3); }
|
||||
.admin-wizard { display: grid; gap: var(--space-4); margin: var(--space-8) 0; }
|
||||
.admin-wizard__nav { display: flex; justify-content: space-between; gap: var(--space-3); }
|
||||
.admin-wizard > [role="region"]:focus { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
|
||||
.admin-list { margin-top: var(--space-8); }
|
||||
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
|
||||
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
||||
@@ -75,7 +82,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
||||
.button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); }
|
||||
.button--primary { background: var(--color-interactive); color: var(--color-on-interactive); }
|
||||
.button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
|
||||
.button--danger { background: var(--color-danger-text); color: var(--color-white); }
|
||||
.button--danger { background: var(--color-danger-solid); color: var(--color-white); }
|
||||
.button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
.swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); }
|
||||
.swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
|
||||
@@ -120,3 +127,5 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
|
||||
.admin-shell__content { padding: var(--space-6); }
|
||||
.admin-workspace-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.public-page-focus:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: -3px; }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.catalog-sort-select {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.catalog-sort-select span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.catalog-sort-select select {
|
||||
min-height: 44px;
|
||||
border: var(--border-thin) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import CatalogSortSelect from './CatalogSortSelect.vue';
|
||||
|
||||
export default { title: 'Atoms/CatalogSortSelect', component: CatalogSortSelect };
|
||||
|
||||
export const RecentlyAdded = { args: { modelValue: 'recent' } };
|
||||
export const NameAscending = { args: { modelValue: 'name_asc' } };
|
||||
export const MarketValueDescending = { args: { modelValue: 'market_high' } };
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: String, required: true },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="catalog-sort-select">
|
||||
<span>Sort</span>
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option value="recent">Recently added</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
<option value="price_low">Price: low to high</option>
|
||||
<option value="price_high">Price: high to low</option>
|
||||
<option value="market_low">Market value: low to high</option>
|
||||
<option value="market_high">Market value: high to low</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './CatalogSortSelect.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
.catalog-text-filter {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.catalog-text-filter span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.catalog-text-filter input {
|
||||
min-height: 44px;
|
||||
border: var(--border-thin) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import CatalogTextFilter from './CatalogTextFilter.vue';
|
||||
|
||||
export default { title: 'Atoms/CatalogTextFilter', component: CatalogTextFilter };
|
||||
|
||||
export const Set = { args: { label: 'Set', modelValue: '', placeholder: 'SV151' } };
|
||||
export const Tag = { args: { label: 'Tag', modelValue: 'binder' } };
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
modelValue: { type: String, required: true },
|
||||
placeholder: { type: String, default: '' },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="catalog-text-filter">
|
||||
<span>{{ label }}</span>
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="search"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './CatalogTextFilter.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
.catalog-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.catalog-toggle input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--color-interactive);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import CatalogToggle from './CatalogToggle.vue';
|
||||
|
||||
export default { title: 'Atoms/CatalogToggle', component: CatalogToggle };
|
||||
|
||||
export const NewlyAdded = { args: { label: 'Newly added', modelValue: false } };
|
||||
export const IncludeSold = { args: { label: 'Include sold', modelValue: true } };
|
||||
@@ -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 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>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
interactive: { type: Boolean, default: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
});
|
||||
defineEmits(['select']);
|
||||
</script>
|
||||
|
||||
<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 }}
|
||||
</button>
|
||||
<span v-else class="condition">{{ label }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -15,13 +15,13 @@ const computedBlockers = computed(() => props.blockers.length ? props.blockers :
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="admin-listing-status">
|
||||
<article class="admin-listing-status" :aria-labelledby="`listing-title-${listing.id}`">
|
||||
<div class="admin-listing-status__identity">
|
||||
<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>
|
||||
</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">
|
||||
<li v-for="blocker in computedBlockers" :key="blocker">{{ blocker }}</li>
|
||||
</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 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__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.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";
|
||||
|
||||
export default { title: "Molecules/ImageGallery", component: ImageGallery };
|
||||
@@ -47,3 +48,10 @@ export const LoadingImage = {
|
||||
},
|
||||
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"
|
||||
role="img"
|
||||
:aria-label="
|
||||
props.images[selectedIndex]?.src
|
||||
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
|
||||
? 'Collectible image unavailable'
|
||||
: 'No collectible image available'
|
||||
"
|
||||
>{{
|
||||
props.images[selectedIndex]?.src
|
||||
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
|
||||
? "IMAGE UNAVAILABLE"
|
||||
: (props.images[selectedIndex]?.label ?? "IMAGE")
|
||||
}}</span
|
||||
@@ -83,6 +83,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
||||
<div
|
||||
v-if="props.images.length > 1"
|
||||
class="image-gallery__controls"
|
||||
role="group"
|
||||
aria-label="Gallery images"
|
||||
>
|
||||
<button
|
||||
@@ -90,7 +91,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
|
||||
:key="image.id"
|
||||
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
|
||||
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"
|
||||
@click="
|
||||
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);
|
||||
await userEvent.tab();
|
||||
await expect(canvas.getByRole('link')).toHaveFocus();
|
||||
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/listings/listing-1');
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
<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 StatusBadge from '@/components/atoms/StatusBadge.vue';
|
||||
|
||||
@@ -11,14 +16,14 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<StatusBadge :status="listing.status" />
|
||||
<span class="listing-card__name">{{ listing.productName }}</span>
|
||||
<PriceDisplay :amount-minor="listing.askingPriceMinor" />
|
||||
</span>
|
||||
</a>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<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
Reference in New Issue
Block a user