feat: reconcile application vertical slices

Backfill the completed acquisitions, sales, dashboard, pricing, search, public detail, worker, migration, contracts, validation, tests, and implementation-status changes into one verified application slice.
This commit is contained in:
2026-09-09 22:12:53 -04:00
parent b9ff7445a6
commit b8f089a5d9
78 changed files with 4561 additions and 2057 deletions
@@ -1,6 +1,10 @@
import { and, desc, eq } from 'drizzle-orm';
import { and, desc, eq } from "drizzle-orm";
import { acquisitions, inventoryItems, products } from '../../../infrastructure/database/schema.js';
import {
acquisitions,
inventoryItems,
products,
} from "../../../infrastructure/database/schema.js";
export function createAcquisitionRepository(db) {
return {
@@ -10,11 +14,19 @@ export function createAcquisitionRepository(db) {
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId)));
.where(
and(
eq(inventoryItems.id, acquisition.inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: 'NOT_FOUND' };
const [created] = await transaction.insert(acquisitions).values(acquisition).returning();
return { status: 'CREATED', acquisition: created };
if (!ownedItem) return { status: "NOT_FOUND" };
const [created] = await transaction
.insert(acquisitions)
.values(acquisition)
.returning();
return { status: "CREATED", acquisition: created };
});
},
@@ -22,11 +34,14 @@ export function createAcquisitionRepository(db) {
return db
.select({ acquisition: acquisitions })
.from(acquisitions)
.innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, acquisitions.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(acquisitions.createdAt))
.then((rows) => rows.map(({ acquisition }) => acquisition));
},
};
}
}
@@ -1,42 +1,64 @@
import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition';
import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition';
import { toAdminAcquisitionDto } from "@pokemon-inventory/contracts/acquisition";
import { createAcquisitionSchema } from "@pokemon-inventory/validation/acquisition";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminAcquisitionRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
acquisitionService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/acquisitions', async (request) => ({
data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto),
app.get("/acquisitions", async (request) => ({
data: (
await acquisitionService.list({ sellerId: request.admin.sellerId })
).map(toAdminAcquisitionDto),
}));
app.post('/acquisitions', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/acquisitions", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createAcquisitionSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId });
const acquisition = await acquisitionService.create({
...result.data,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) });
});
}
}
@@ -1,13 +1,23 @@
import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import {
createAcquisition,
calculateAcquisitionCostBasis,
} from "../domain/acquisition.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createAcquisitionService({ acquisitionRepository }) {
return {
async create({ sellerId, ...input }) {
const acquisition = createAcquisition(input);
const result = await acquisitionRepository.createForSeller({ acquisition, sellerId });
if (result.status === 'NOT_FOUND') {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await acquisitionRepository.createForSeller({
acquisition,
sellerId,
});
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
}
return {
@@ -1,20 +1,33 @@
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard';
import { toAdminDashboardDto } from "@pokemon-inventory/contracts/dashboard";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
export async function registerAdminDashboardRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
dashboardService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/dashboard', async (request) => ({
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
app.get("/dashboard", async (request) => ({
data: toAdminDashboardDto(
await dashboardService.get({ sellerId: request.admin.sellerId }),
),
}));
}
}
@@ -1,7 +1,23 @@
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService, pricingRepository, pricingRefreshJobRepository }) {
export function createDashboardService({
productService,
inventoryItemService,
listingService,
saleService,
acquisitionService,
pricingRepository,
pricingRefreshJobRepository,
}) {
return {
async get({ sellerId }) {
const [products, inventory, listings, sales, acquisitions, valuations, failedPricingJobs] = await Promise.all([
const [
products,
inventory,
listings,
sales,
acquisitions,
valuations,
failedPricingJobs,
] = await Promise.all([
productService.listBySellerId(sellerId),
inventoryItemService.listBySellerId(sellerId),
listingService.list({ sellerId }),
@@ -11,36 +27,51 @@ export function createDashboardService({ productService, inventoryItemService, l
pricingRefreshJobRepository?.countFailedForSeller(sellerId) ?? 0,
]);
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
const valuationByItem = new Map(valuations.map((valuation) => [valuation.inventoryItemId, valuation]));
const activeListings = listings.filter(({ status }) =>
["AVAILABLE", "PENDING"].includes(status),
);
const completedSales = sales.filter(({ status }) => status !== "VOIDED");
const valuationByItem = new Map(
valuations.map((valuation) => [valuation.inventoryItemId, valuation]),
);
const staleThreshold = 7 * 24 * 60 * 60 * 1000;
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
const total = (values, field) =>
values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
return {
counts: {
products: products.length,
inventory: inventory.length,
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
activeInventory: inventory.filter(({ status }) => status === "ACTIVE")
.length,
soldInventory: inventory.filter(({ status }) => status === "SOLD")
.length,
availableListings: activeListings.length,
sales: completedSales.length,
acquisitions: acquisitions.length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id)).length,
stalePricing: valuations.filter(({ calculatedAt }) => Date.now() - new Date(calculatedAt).getTime() > staleThreshold).length,
missingImages: listings.filter(({ imageCount }) => imageCount < 1)
.length,
missingPricing: inventory.filter(({ id }) => !valuationByItem.has(id))
.length,
stalePricing: valuations.filter(
({ calculatedAt }) =>
Date.now() - new Date(calculatedAt).getTime() > staleThreshold,
).length,
failedPricingJobs,
},
financials: {
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
totalProfitMinor: total(completedSales, 'profitMinor'),
totalMarketValueMinor: total([...valuationByItem.values()], 'valueMinor'),
totalAcquisitionCostMinor: total(acquisitions, "costBasisMinor"),
totalAskingValueMinor: total(activeListings, "askingPriceMinor"),
totalNetSaleProceedsMinor: total(completedSales, "netProceedsMinor"),
totalProfitMinor: total(completedSales, "profitMinor"),
totalMarketValueMinor: total(
[...valuationByItem.values()],
"valueMinor",
),
},
recentSales: completedSales.slice(0, 5),
recentAcquisitions: acquisitions.slice(0, 5),
};
},
};
}
}
@@ -1,11 +1,13 @@
export async function registerPublicLookupRoutes(app, options) {
const { catalogLookupRepository } = options;
app.get('/public/lookups/categories', async () => ({
app.get("/public/lookups/categories", async () => ({
data: await catalogLookupRepository.listCategories(),
}));
app.get('/public/lookups/conditions', async () => ({
data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })),
app.get("/public/lookups/conditions", async () => ({
data: (await catalogLookupRepository.listConditions()).map(
({ id, name, code }) => ({ id, name, code }),
),
}));
}
}
@@ -1,4 +1,14 @@
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql,
} from "drizzle-orm";
import {
conditions,
@@ -7,12 +17,15 @@ import {
marketValueOverrides,
marketValuations,
products,
} from '../../../infrastructure/database/schema.js';
} from "../../../infrastructure/database/schema.js";
export function createListingRepository(db) {
return {
async create(listing) {
const [createdListing] = await db.insert(listings).values(listing).returning();
const [createdListing] = await db
.insert(listings)
.values(listing)
.returning();
return createdListing;
},
@@ -28,18 +41,28 @@ export function createListingRepository(db) {
const [result] = await db
.select({ listing: listings, inventoryStatus: inventoryItems.status })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
.where(
and(eq(listings.id, listingId), eq(products.sellerId, sellerId)),
);
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
return result
? { ...result.listing, inventoryStatus: result.inventoryStatus }
: null;
},
async listBySellerId(sellerId) {
const results = await db
.select({ listing: listings })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(listings.createdAt));
@@ -49,7 +72,7 @@ export function createListingRepository(db) {
async searchPublic(filters = {}) {
const {
text = '',
text = "",
category,
conditionId,
minPriceMinor,
@@ -57,35 +80,40 @@ export function createListingRepository(db) {
page = 1,
limit = 20,
includeSold = false,
sort = 'recent',
sort = "recent",
} = filters;
const conditions = [
inArray(
listings.status,
includeSold ? ['AVAILABLE', 'PENDING', 'CLOSED'] : ['AVAILABLE', 'PENDING'],
includeSold
? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"],
),
inArray(
inventoryItems.status,
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
),
inArray(inventoryItems.status, includeSold ? ['ACTIVE', 'SOLD'] : ['ACTIVE']),
];
if (typeof category === 'string' && category.trim().length > 0) {
if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category));
}
if (typeof conditionId === 'string' && conditionId.trim().length > 0) {
if (typeof conditionId === "string" && conditionId.trim().length > 0) {
conditions.push(eq(inventoryItems.conditionId, conditionId));
}
if (typeof minPriceMinor === 'number') {
if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
}
if (typeof maxPriceMinor === 'number') {
if (typeof maxPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor <= ${maxPriceMinor}`);
}
if (typeof text === 'string' && text.trim().length > 0) {
const searchTerms = text.split(' ').filter(Boolean);
if (typeof text === "string" && text.trim().length > 0) {
const searchTerms = text.split(" ").filter(Boolean);
if (searchTerms.length > 0) {
conditions.push(
@@ -105,28 +133,35 @@ export function createListingRepository(db) {
}
const offset = (page - 1) * limit;
const sortOrder = sort === 'price_low'
? listings.askingPriceMinor
: sort === 'price_high'
? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const sortOrder =
sort === "price_low"
? listings.askingPriceMinor
: sort === "price_high"
? desc(listings.askingPriceMinor)
: desc(listings.createdAt);
const [countResult, results] = await Promise.all([
db
.select({ total: count() })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions)),
db
.select({ listing: listings })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
.select({ listing: listings })
.from(listings)
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(...conditions))
.orderBy(sortOrder, desc(listings.createdAt))
.limit(limit)
.offset(offset),
]);
return {
@@ -139,22 +174,40 @@ export function createListingRepository(db) {
const [result] = await db
.select({ listing: listings, condition: conditions })
.from(listings)
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, listings.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(conditions, eq(conditions.id, inventoryItems.conditionId))
.where(and(
eq(listings.id, listingId),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryItems.status, 'ACTIVE'),
));
.where(
and(
eq(listings.id, listingId),
inArray(listings.status, ["AVAILABLE", "PENDING"]),
eq(inventoryItems.status, "ACTIVE"),
),
);
if (!result) return null;
const [override] = await db.select().from(marketValueOverrides)
.where(eq(marketValueOverrides.inventoryItemId, result.listing.inventoryItemId))
.orderBy(desc(marketValueOverrides.createdAt)).limit(1);
const [valuation] = await db.select().from(marketValuations)
.where(eq(marketValuations.inventoryItemId, result.listing.inventoryItemId))
.orderBy(desc(marketValuations.calculatedAt)).limit(1);
const [override] = await db
.select()
.from(marketValueOverrides)
.where(
eq(
marketValueOverrides.inventoryItemId,
result.listing.inventoryItemId,
),
)
.orderBy(desc(marketValueOverrides.createdAt))
.limit(1);
const [valuation] = await db
.select()
.from(marketValuations)
.where(
eq(marketValuations.inventoryItemId, result.listing.inventoryItemId),
)
.orderBy(desc(marketValuations.calculatedAt))
.limit(1);
return {
...result.listing,
conditionName: result.condition.name,
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`);
}
@@ -19,28 +19,30 @@ export function createPricingObservation({
inventoryItemId,
providerId,
priceMinor,
currency = 'USD',
currency = "USD",
sourceIdentifier,
conditionId,
sourceUrl,
qualityScore,
}) {
return {
productId: requiredText(productId, 'Product ID'),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
providerId: requiredText(providerId, 'Provider ID'),
priceMinor: requirePositiveMinorUnit(priceMinor, 'Observed price'),
currency: requiredText(currency, 'Currency'),
sourceIdentifier: requiredText(sourceIdentifier, 'Source identifier'),
conditionId: requiredText(conditionId, 'Condition ID'),
...(typeof sourceUrl === 'string' && sourceUrl.trim().length > 0 ? { sourceUrl: sourceUrl.trim() } : {}),
...(typeof qualityScore === 'number' ? { qualityScore } : {}),
productId: requiredText(productId, "Product ID"),
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
providerId: requiredText(providerId, "Provider ID"),
priceMinor: requirePositiveMinorUnit(priceMinor, "Observed price"),
currency: requiredText(currency, "Currency"),
sourceIdentifier: requiredText(sourceIdentifier, "Source identifier"),
conditionId: requiredText(conditionId, "Condition ID"),
...(typeof sourceUrl === "string" && sourceUrl.trim().length > 0
? { sourceUrl: sourceUrl.trim() }
: {}),
...(typeof qualityScore === "number" ? { qualityScore } : {}),
};
}
export function calculateMedianPriceMinor(prices) {
if (!Array.isArray(prices) || prices.length === 0) {
throw new Error('At least one price is required.');
throw new Error("At least one price is required.");
}
const sorted = [...prices].sort((left, right) => left - right);
@@ -57,10 +59,13 @@ export function identifyOutlierPrices(prices) {
if (!Array.isArray(prices) || prices.length < 4) return new Set();
const sorted = [...prices].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const median =
sorted.length % 2
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
const lowerFence = median / 3;
const upperFence = median * 3;
return new Set(prices.filter((price) => price < lowerFence || price > upperFence));
return new Set(
prices.filter((price) => price < lowerFence || price > upperFence),
);
}
@@ -1,18 +1,20 @@
import { createPricingProvider } from './pricing-provider.js';
import { createPricingProvider } from "./pricing-provider.js";
export function createFixturePricingProvider() {
return createPricingProvider({
id: 'fixture-provider',
id: "fixture-provider",
async observe({ productId, inventoryItemId, conditionId }) {
return [{
productId,
inventoryItemId,
conditionId,
priceMinor: 2500,
currency: 'USD',
sourceIdentifier: `fixture-${inventoryItemId}`,
qualityScore: 50,
}];
return [
{
productId,
inventoryItemId,
conditionId,
priceMinor: 2500,
currency: "USD",
sourceIdentifier: `fixture-${inventoryItemId}`,
qualityScore: 50,
},
];
},
});
}
@@ -1,10 +1,10 @@
export function createPricingProvider({ id, observe }) {
if (typeof id !== 'string' || id.trim().length === 0) {
throw new Error('Pricing provider id is required.');
if (typeof id !== "string" || id.trim().length === 0) {
throw new Error("Pricing provider id is required.");
}
if (typeof observe !== 'function') {
throw new TypeError('Pricing provider observe function is required.');
if (typeof observe !== "function") {
throw new TypeError("Pricing provider observe function is required.");
}
return Object.freeze({
@@ -12,7 +12,9 @@ export function createPricingProvider({ id, observe }) {
async observe(input) {
const observations = await observe(input);
if (!Array.isArray(observations)) {
throw new Error(`Pricing provider ${id} must return an observation array.`);
throw new Error(
`Pricing provider ${id} must return an observation array.`,
);
}
return observations;
},
@@ -20,7 +22,9 @@ export function createPricingProvider({ id, observe }) {
}
export function createPricingProviderRegistry(providers = []) {
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
const providerMap = new Map(
providers.map((provider) => [provider.id, provider]),
);
return {
get(providerId) {
@@ -1,95 +1,181 @@
import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm';
import { and, asc, desc, eq, isNull, lte, or } from "drizzle-orm";
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
import { pricingRefreshJobs } from "../../../infrastructure/database/schema.js";
export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3;
return {
async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
const [created] = await db
.insert(pricingRefreshJobs)
.values(job)
.returning();
return created;
},
async claimNext() {
return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs)
.where(and(
eq(pricingRefreshJobs.status, 'PENDING'),
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
))
const [job] = await transaction
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.status, "PENDING"),
or(
isNull(pricingRefreshJobs.nextAttemptAt),
lte(pricingRefreshJobs.nextAttemptAt, new Date()),
),
),
)
.orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1)
.for('update', { skipLocked: true });
.for("update", { skipLocked: true });
if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs)
.set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
const [claimed] = await transaction
.update(pricingRefreshJobs)
.set({
status: "PROCESSING",
attemptCount: pricingRefreshJobs.attemptCount,
updatedAt: new Date(),
error: null,
nextAttemptAt: null,
})
.where(
and(
eq(pricingRefreshJobs.id, job.id),
eq(pricingRefreshJobs.status, "PENDING"),
),
)
.returning();
return claimed ?? null;
});
},
async findByIdForSeller(id, sellerId) {
const [job] = await db.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId)));
const [job] = await db
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
),
);
return job ?? null;
},
async countFailedForSeller(sellerId) {
const rows = await db.select({ id: pricingRefreshJobs.id })
const rows = await db
.select({ id: pricingRefreshJobs.id })
.from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')));
.where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
),
);
return rows.length;
},
async listForSeller(sellerId) {
return db.select().from(pricingRefreshJobs)
return db
.select()
.from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.sellerId, sellerId))
.orderBy(desc(pricingRefreshJobs.createdAt));
},
async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs)
.set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
const [job] = await db
.update(pricingRefreshJobs)
.set({
status: "PENDING",
error: null,
observations: [],
valuation: null,
nextAttemptAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "FAILED"),
lte(pricingRefreshJobs.attemptCount, maxAttempts - 1),
),
)
.returning();
return job ?? null;
},
async complete(id, observations, valuation) {
const [completed] = await db.update(pricingRefreshJobs)
.set({ status: 'COMPLETED', observations, valuation, error: null, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
const [completed] = await db
.update(pricingRefreshJobs)
.set({
status: "COMPLETED",
observations,
valuation,
error: null,
updatedAt: new Date(),
})
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning();
return completed ?? null;
},
async fail(id, message) {
return db.transaction(async (transaction) => {
const [current] = await transaction.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
const [current] = await transaction
.select()
.from(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
);
if (!current) return null;
const attemptCount = current.attemptCount + 1;
const retryable = attemptCount < maxAttempts;
const [updated] = await transaction.update(pricingRefreshJobs)
const [updated] = await transaction
.update(pricingRefreshJobs)
.set({
status: retryable ? 'PENDING' : 'FAILED',
status: retryable ? "PENDING" : "FAILED",
error: message,
attemptCount,
updatedAt: new Date(),
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null,
nextAttemptAt: retryable
? new Date(Date.now() + 2 ** attemptCount * 1000)
: null,
})
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.where(
and(
eq(pricingRefreshJobs.id, id),
eq(pricingRefreshJobs.status, "PROCESSING"),
),
)
.returning();
return updated ?? null;
});
},
async purgeCompletedBefore(sellerId, cutoff) {
const deleted = await db.delete(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
const deleted = await db
.delete(pricingRefreshJobs)
.where(
and(
eq(pricingRefreshJobs.sellerId, sellerId),
eq(pricingRefreshJobs.status, "COMPLETED"),
lte(pricingRefreshJobs.updatedAt, cutoff),
),
)
.returning({ id: pricingRefreshJobs.id });
return deleted.length;
},
};
}
}
@@ -1,7 +1,14 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, desc, eq, inArray } from "drizzle-orm";
import { inventoryItems, marketValueOverrides, marketValuations, pricingObservations, products, securityAuditEvents } from '../../../infrastructure/database/schema.js';
import { identifyOutlierPrices } from '../domain/pricing-observation.js';
import {
inventoryItems,
marketValueOverrides,
marketValuations,
pricingObservations,
products,
securityAuditEvents,
} from "../../../infrastructure/database/schema.js";
import { identifyOutlierPrices } from "../domain/pricing-observation.js";
export function createPricingRepository(db) {
return {
@@ -10,77 +17,165 @@ export function createPricingRepository(db) {
.select({ id: inventoryItems.id, productId: inventoryItems.productId })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, observation.inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem || ownedItem.productId !== observation.productId) return { status: 'NOT_FOUND' };
const [created] = await db.insert(pricingObservations).values(observation).returning();
return { status: 'CREATED', observation: created };
.where(
and(
eq(inventoryItems.id, observation.inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem || ownedItem.productId !== observation.productId)
return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingObservations)
.values(observation)
.returning();
return { status: "CREATED", observation: created };
},
async calculateForSeller({ inventoryItemId, sellerId, strategy = 'MEDIAN' }) {
async calculateForSeller({
inventoryItemId,
sellerId,
strategy = "MEDIAN",
}) {
const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const observations = await db.select().from(pricingObservations)
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), eq(pricingObservations.isOutlier, false)))
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
eq(pricingObservations.isOutlier, false),
),
)
.orderBy(desc(pricingObservations.observedAt));
if (!observations.length) return { status: 'NO_DATA' };
const prices = observations.map(({ priceMinor }) => priceMinor).sort((left, right) => left - right);
if (!observations.length) return { status: "NO_DATA" };
const prices = observations
.map(({ priceMinor }) => priceMinor)
.sort((left, right) => left - right);
const middle = Math.floor(prices.length / 2);
const valueMinor = prices.length % 2 ? prices[middle] : Math.round((prices[middle - 1] + prices[middle]) / 2);
const [valuation] = await db.insert(marketValuations).values({
inventoryItemId,
valueMinor,
currency: 'USD',
strategy,
confidence: observations.length === 1 ? 'LOW' : 'MEDIUM',
sourceCount: observations.length,
}).returning();
return { status: 'CALCULATED', valuation, observations };
const valueMinor =
prices.length % 2
? prices[middle]
: Math.round((prices[middle - 1] + prices[middle]) / 2);
const [valuation] = await db
.insert(marketValuations)
.values({
inventoryItemId,
valueMinor,
currency: "USD",
strategy,
confidence: observations.length === 1 ? "LOW" : "MEDIUM",
sourceCount: observations.length,
})
.returning();
return { status: "CALCULATED", valuation, observations };
},
async listLatestValuationsForSeller(sellerId) {
const rows = await db.select({ valuation: marketValuations, inventoryItemId: inventoryItems.id })
const rows = await db
.select({
valuation: marketValuations,
inventoryItemId: inventoryItems.id,
})
.from(marketValuations)
.innerJoin(inventoryItems, eq(inventoryItems.id, marketValuations.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, marketValuations.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(products.sellerId, sellerId))
.orderBy(desc(marketValuations.calculatedAt));
const latest = new Map();
for (const row of rows) {
if (!latest.has(row.inventoryItemId)) latest.set(row.inventoryItemId, row.valuation);
if (!latest.has(row.inventoryItemId))
latest.set(row.inventoryItemId, row.valuation);
}
return [...latest.values()];
},
async markOutliersForSeller({ inventoryItemId, sellerId }) {
const [ownedItem] = await db.select({ id: inventoryItems.id }).from(inventoryItems)
const [ownedItem] = await db
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const observations = await db.select().from(pricingObservations).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const observations = await db
.select()
.from(pricingObservations)
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
const prices = observations.map(({ priceMinor }) => priceMinor);
const outlierPrices = identifyOutlierPrices(prices);
await db.update(pricingObservations).set({ isOutlier: false, outlierReason: null }).where(eq(pricingObservations.inventoryItemId, inventoryItemId));
await db
.update(pricingObservations)
.set({ isOutlier: false, outlierReason: null })
.where(eq(pricingObservations.inventoryItemId, inventoryItemId));
if (outlierPrices.size) {
await db.update(pricingObservations).set({ isOutlier: true, outlierReason: 'Outside the 1.5 IQR range.' })
.where(and(eq(pricingObservations.inventoryItemId, inventoryItemId), inArray(pricingObservations.priceMinor, [...outlierPrices])));
await db
.update(pricingObservations)
.set({ isOutlier: true, outlierReason: "Outside the 1.5 IQR range." })
.where(
and(
eq(pricingObservations.inventoryItemId, inventoryItemId),
inArray(pricingObservations.priceMinor, [...outlierPrices]),
),
);
}
return { status: 'UPDATED' };
return { status: "UPDATED" };
},
async createOverride({ inventoryItemId, sellerId, actorUserId, valueMinor, currency, reason }) {
async createOverride({
inventoryItemId,
sellerId,
actorUserId,
valueMinor,
currency,
reason,
}) {
return db.transaction(async (transaction) => {
const [ownedItem] = await transaction.select({ id: inventoryItems.id }).from(inventoryItems)
const [ownedItem] = await transaction
.select({ id: inventoryItems.id })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(inventoryItems.id, inventoryItemId), eq(products.sellerId, sellerId)));
if (!ownedItem) return { status: 'NOT_FOUND' };
const [override] = await transaction.insert(marketValueOverrides).values({ inventoryItemId, valueMinor, currency, reason, createdBy: actorUserId }).returning();
await transaction.insert(securityAuditEvents).values({ userId: actorUserId, event: 'MARKET_VALUE_OVERRIDDEN', metadata: { inventoryItemId, overrideId: override.id, reason } });
return { status: 'CREATED', override };
.where(
and(
eq(inventoryItems.id, inventoryItemId),
eq(products.sellerId, sellerId),
),
);
if (!ownedItem) return { status: "NOT_FOUND" };
const [override] = await transaction
.insert(marketValueOverrides)
.values({
inventoryItemId,
valueMinor,
currency,
reason,
createdBy: actorUserId,
})
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: "MARKET_VALUE_OVERRIDDEN",
metadata: { inventoryItemId, overrideId: override.id, reason },
});
return { status: "CREATED", override };
});
},
};
}
}
@@ -1,25 +1,42 @@
import { and, eq } from 'drizzle-orm';
import { and, eq } from "drizzle-orm";
import { pricingProviderMappings, products } from '../../../infrastructure/database/schema.js';
import {
pricingProviderMappings,
products,
} from "../../../infrastructure/database/schema.js";
export function createProviderMappingRepository(db) {
return {
async createForSeller(mapping, sellerId) {
const [ownedProduct] = await db.select({ id: products.id }).from(products)
.where(and(eq(products.id, mapping.productId), eq(products.sellerId, sellerId)));
if (!ownedProduct) return { status: 'NOT_FOUND' };
const [created] = await db.insert(pricingProviderMappings).values(mapping).returning();
return { status: 'CREATED', mapping: created };
const [ownedProduct] = await db
.select({ id: products.id })
.from(products)
.where(
and(
eq(products.id, mapping.productId),
eq(products.sellerId, sellerId),
),
);
if (!ownedProduct) return { status: "NOT_FOUND" };
const [created] = await db
.insert(pricingProviderMappings)
.values(mapping)
.returning();
return { status: "CREATED", mapping: created };
},
async listForSeller(productId, sellerId) {
return db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
return db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
))
.where(
and(
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, "ACTIVE"),
),
)
.then((rows) => rows.map(({ mapping }) => mapping));
},
@@ -27,29 +44,41 @@ export function createProviderMappingRepository(db) {
const conditions = [
eq(pricingProviderMappings.productId, productId),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
eq(pricingProviderMappings.status, "ACTIVE"),
];
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
if (providerId)
conditions.push(eq(pricingProviderMappings.providerId, providerId));
const [row] = await db
.select({ mapping: pricingProviderMappings })
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(...conditions));
return row?.mapping ?? null;
},
async disableForSeller(id, sellerId) {
const [mapping] = await db.select().from(pricingProviderMappings)
const [mapping] = await db
.select()
.from(pricingProviderMappings)
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
.where(and(
eq(pricingProviderMappings.id, id),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, 'ACTIVE'),
));
if (!mapping) return { status: 'NOT_FOUND' };
const [updated] = await db.update(pricingProviderMappings)
.set({ status: 'DISABLED', disabledAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(pricingProviderMappings.id, id),
eq(products.sellerId, sellerId),
eq(pricingProviderMappings.status, "ACTIVE"),
),
);
if (!mapping) return { status: "NOT_FOUND" };
const [updated] = await db
.update(pricingProviderMappings)
.set({
status: "DISABLED",
disabledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pricingProviderMappings.id, id))
.returning();
return { status: 'DISABLED', mapping: updated };
return { status: "DISABLED", mapping: updated };
},
};
}
@@ -1,48 +1,85 @@
import { toAdminMarketValuationDto, toAdminPricingObservationDto } from '@pokemon-inventory/contracts/pricing';
import { createMarketValueOverrideSchema, createPricingObservationSchema } from '@pokemon-inventory/validation/pricing';
import {
toAdminMarketValuationDto,
toAdminPricingObservationDto,
} from "@pokemon-inventory/contracts/pricing";
import {
createMarketValueOverrideSchema,
createPricingObservationSchema,
} from "@pokemon-inventory/validation/pricing";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminPricingRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
pricingService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.post('/pricing/observations', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/pricing/observations", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createPricingObservationSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const observation = await pricingService.addObservation({ ...result.data, sellerId: request.admin.sellerId });
return reply.code(201).send({ data: toAdminPricingObservationDto(observation) });
const observation = await pricingService.addObservation({
...result.data,
sellerId: request.admin.sellerId,
});
return reply
.code(201)
.send({ data: toAdminPricingObservationDto(observation) });
});
app.post('/inventory/:id/valuation', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
app.post("/inventory/:id/valuation", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const valuation = await pricingService.calculate({
sellerId: request.admin.sellerId,
inventoryItemId: request.params.id,
});
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
});
app.post('/inventory/:id/valuation/override', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/inventory/:id/valuation/override", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createMarketValueOverrideSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const override = await pricingService.override({
@@ -54,8 +91,11 @@ export async function registerAdminPricingRoutes(app, options) {
return reply.code(201).send({ data: override });
});
app.post('/inventory/:id/pricing/refresh', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const { providerId, productId, conditionId } = request.body ?? {};
const job = await pricingService.enqueueRefresh({
sellerId: request.admin.sellerId,
@@ -64,11 +104,16 @@ export async function registerAdminPricingRoutes(app, options) {
productId,
conditionId,
});
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
app.get('/jobs/:id', async (request) => {
const job = await pricingService.getJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
app.get("/jobs/:id", async (request) => {
const job = await pricingService.getJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return {
data: {
id: job.id,
@@ -81,16 +126,31 @@ export async function registerAdminPricingRoutes(app, options) {
};
});
app.get('/jobs', async (request) => ({
data: (await pricingService.listJobs({ sellerId: request.admin.sellerId })).map((job) => ({
id: job.id, status: job.status, providerId: job.providerId, inventoryItemId: job.inventoryItemId,
error: job.error ?? null, createdAt: job.createdAt, updatedAt: job.updatedAt,
app.get("/jobs", async (request) => ({
data: (
await pricingService.listJobs({ sellerId: request.admin.sellerId })
).map((job) => ({
id: job.id,
status: job.status,
providerId: job.providerId,
inventoryItemId: job.inventoryItemId,
error: job.error ?? null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
})),
}));
app.post('/jobs/:id/retry', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const job = await pricingService.retryJob({ sellerId: request.admin.sellerId, jobId: request.params.id });
return reply.code(202).send({ data: { id: job.id, status: job.status, createdAt: job.createdAt } });
app.post("/jobs/:id/retry", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const job = await pricingService.retryJob({
sellerId: request.admin.sellerId,
jobId: request.params.id,
});
return reply.code(202).send({
data: { id: job.id, status: job.status, createdAt: job.createdAt },
});
});
}
}
@@ -1,47 +1,79 @@
import { toAdminProviderMappingDto } from '@pokemon-inventory/contracts/provider-mapping';
import { createProviderMappingSchema } from '@pokemon-inventory/validation/provider-mapping';
import { toAdminProviderMappingDto } from "@pokemon-inventory/contracts/provider-mapping";
import { createProviderMappingSchema } from "@pokemon-inventory/validation/provider-mapping";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
export async function registerAdminProviderMappingRoutes(app, options) {
const {
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
authenticateAdmin = async () => {
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sessionCookieName = "pokemon_inventory_session",
providerMappingService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
if (!admin?.sellerId)
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
request.admin = admin;
});
app.get('/products/:id/provider-mappings', async (request) => ({
data: (await providerMappingService.list({ productId: request.params.id, sellerId: request.admin.sellerId })).map(toAdminProviderMappingDto),
app.get("/products/:id/provider-mappings", async (request) => ({
data: (
await providerMappingService.list({
productId: request.params.id,
sellerId: request.admin.sellerId,
})
).map(toAdminProviderMappingDto),
}));
app.post('/products/:id/provider-mappings', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
app.post("/products/:id/provider-mappings", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const result = createProviderMappingSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
const mapping = await providerMappingService.create({ ...result.data, productId: request.params.id, sellerId: request.admin.sellerId });
const mapping = await providerMappingService.create({
...result.data,
productId: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(201).send({ data: toAdminProviderMappingDto(mapping) });
});
app.post('/provider-mappings/:id/disable', async (request, reply) => {
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
const mapping = await providerMappingService.disable({ id: request.params.id, sellerId: request.admin.sellerId });
app.post("/provider-mappings/:id/disable", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers["x-csrf-token"],
);
const mapping = await providerMappingService.disable({
id: request.params.id,
sellerId: request.admin.sellerId,
});
return reply.code(200).send({ data: toAdminProviderMappingDto(mapping) });
});
}
@@ -1,66 +1,184 @@
import { createPricingObservation } from '../domain/pricing-observation.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createPricingObservation } from "../domain/pricing-observation.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
export function createPricingService({
pricingRepository,
pricingProviderRegistry,
pricingRefreshJobRepository,
providerMappingRepository,
}) {
return {
async addObservation({ sellerId, ...input }) {
const observation = createPricingObservation(input);
const result = await pricingRepository.addObservationForSeller({ observation, sellerId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await pricingRepository.addObservationForSeller({
observation,
sellerId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.observation;
},
async markOutliers({ sellerId, inventoryItemId }) {
const result = await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
const result = await pricingRepository.markOutliersForSeller({
sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result;
},
async override({ sellerId, actorUserId, inventoryItemId, valueMinor, currency = 'USD', reason }) {
if (!Number.isInteger(valueMinor) || valueMinor < 0) throw createApplicationError('INVALID_OVERRIDE_VALUE', 'Override value must be a non-negative integer minor unit.', 400);
if (typeof reason !== 'string' || reason.trim().length < 3) throw createApplicationError('OVERRIDE_REASON_REQUIRED', 'An override reason is required.', 400);
const result = await pricingRepository.createOverride({ sellerId, actorUserId, inventoryItemId, valueMinor, currency: currency.toUpperCase(), reason: reason.trim() });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
async override({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency = "USD",
reason,
}) {
if (!Number.isInteger(valueMinor) || valueMinor < 0)
throw createApplicationError(
"INVALID_OVERRIDE_VALUE",
"Override value must be a non-negative integer minor unit.",
400,
);
if (typeof reason !== "string" || reason.trim().length < 3)
throw createApplicationError(
"OVERRIDE_REASON_REQUIRED",
"An override reason is required.",
400,
);
const result = await pricingRepository.createOverride({
sellerId,
actorUserId,
inventoryItemId,
valueMinor,
currency: currency.toUpperCase(),
reason: reason.trim(),
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
return result.override;
},
async calculate({ sellerId, inventoryItemId }) {
await pricingRepository.markOutliersForSeller({ sellerId, inventoryItemId });
const result = await pricingRepository.calculateForSeller({ sellerId, inventoryItemId });
if (result.status === 'NOT_FOUND') throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
if (result.status === 'NO_DATA') throw createApplicationError('PRICING_DATA_UNAVAILABLE', 'No pricing observations are available.', 404);
await pricingRepository.markOutliersForSeller({
sellerId,
inventoryItemId,
});
const result = await pricingRepository.calculateForSeller({
sellerId,
inventoryItemId,
});
if (result.status === "NOT_FOUND")
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
if (result.status === "NO_DATA")
throw createApplicationError(
"PRICING_DATA_UNAVAILABLE",
"No pricing observations are available.",
404,
);
return result;
},
async refresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
async refresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
const provider = pricingProviderRegistry?.get(providerId);
if (!provider) {
throw createApplicationError('PRICING_PROVIDER_UNAVAILABLE', 'Pricing provider is not configured.', 503);
throw createApplicationError(
"PRICING_PROVIDER_UNAVAILABLE",
"Pricing provider is not configured.",
503,
);
}
let providerObservations;
try {
providerObservations = await provider.observe({ inventoryItemId, productId, conditionId });
providerObservations = await provider.observe({
inventoryItemId,
productId,
conditionId,
});
} catch (error) {
throw createApplicationError('PRICING_PROVIDER_FAILED', error.message, 502);
throw createApplicationError(
"PRICING_PROVIDER_FAILED",
error.message,
502,
);
}
const observations = [];
for (const input of providerObservations) {
observations.push(await this.addObservation({ sellerId, ...input, providerId: provider.id, inventoryItemId, productId, conditionId }));
observations.push(
await this.addObservation({
sellerId,
...input,
providerId: provider.id,
inventoryItemId,
productId,
conditionId,
}),
);
}
return { observations, valuation: await this.calculate({ sellerId, inventoryItemId }) };
return {
observations,
valuation: await this.calculate({ sellerId, inventoryItemId }),
};
},
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId);
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409);
async enqueueRefresh({
sellerId,
inventoryItemId,
providerId,
productId,
conditionId,
}) {
if (!pricingRefreshJobRepository)
throw createApplicationError(
"PRICING_QUEUE_UNAVAILABLE",
"Pricing refresh queue is unavailable.",
503,
);
const mapping = await providerMappingRepository?.findActiveForSeller(
productId,
sellerId,
providerId,
);
if (!mapping)
throw createApplicationError(
"PRICING_PROVIDER_MAPPING_NOT_FOUND",
"No active pricing provider mapping exists for this product.",
409,
);
const job = await pricingRefreshJobRepository.create({
sellerId,
inventoryItemId,
providerId: mapping.providerId,
externalIdentifier: mapping.externalIdentifier,
searchQuery: mapping.searchQuery,
productId,
conditionId,
});
@@ -68,8 +186,16 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
},
async getJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.findByIdForSeller(jobId, sellerId);
if (!job) throw createApplicationError('PRICING_JOB_NOT_FOUND', 'Pricing job was not found.', 404);
const job = await pricingRefreshJobRepository?.findByIdForSeller(
jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_FOUND",
"Pricing job was not found.",
404,
);
return job;
},
@@ -78,9 +204,17 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
},
async retryJob({ sellerId, jobId }) {
const job = await pricingRefreshJobRepository?.retryForSeller(jobId, sellerId);
if (!job) throw createApplicationError('PRICING_JOB_NOT_RETRYABLE', 'Pricing job was not found or is not failed.', 409);
const job = await pricingRefreshJobRepository?.retryForSeller(
jobId,
sellerId,
);
if (!job)
throw createApplicationError(
"PRICING_JOB_NOT_RETRYABLE",
"Pricing job was not found or is not failed.",
409,
);
return job;
},
};
}
}
@@ -1,18 +1,34 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createProviderMappingService({ providerMappingRepository }) {
return {
async create({ sellerId, ...input }) {
const result = await providerMappingRepository.createForSeller(input, sellerId);
if (result.status === 'NOT_FOUND') throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.', 404);
const result = await providerMappingRepository.createForSeller(
input,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PRODUCT_NOT_FOUND",
"Product was not found.",
404,
);
return result.mapping;
},
async list({ productId, sellerId }) {
return providerMappingRepository.listForSeller(productId, sellerId);
},
async disable({ id, sellerId }) {
const result = await providerMappingRepository.disableForSeller(id, sellerId);
if (result.status === 'NOT_FOUND') throw createApplicationError('PROVIDER_MAPPING_NOT_FOUND', 'Provider mapping was not found.', 404);
const result = await providerMappingRepository.disableForSeller(
id,
sellerId,
);
if (result.status === "NOT_FOUND")
throw createApplicationError(
"PROVIDER_MAPPING_NOT_FOUND",
"Provider mapping was not found.",
404,
);
return result.mapping;
},
};
+53 -32
View File
@@ -1,5 +1,5 @@
function requiredText(value, fieldName) {
if (typeof value !== 'string' || value.trim().length === 0) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} is required.`);
}
@@ -33,10 +33,10 @@ function requireIsoDate(value, fieldName) {
const [, year, month, day] = match;
const parsedDate = new Date(`${date}T00:00:00.000Z`);
if (
Number.isNaN(parsedDate.getTime())
|| parsedDate.getUTCFullYear() !== Number(year)
|| parsedDate.getUTCMonth() + 1 !== Number(month)
|| parsedDate.getUTCDate() !== Number(day)
Number.isNaN(parsedDate.getTime()) ||
parsedDate.getUTCFullYear() !== Number(year) ||
parsedDate.getUTCMonth() + 1 !== Number(month) ||
parsedDate.getUTCDate() !== Number(day)
) {
throw new Error(`${fieldName} must be a valid ISO date.`);
}
@@ -45,9 +45,9 @@ function requireIsoDate(value, fieldName) {
}
function requireCurrency(value) {
const currency = requiredText(value, 'Currency').toUpperCase();
const currency = requiredText(value, "Currency").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) {
throw new Error('Currency must be a three-letter ISO currency code.');
throw new Error("Currency must be a three-letter ISO currency code.");
}
return currency;
@@ -58,7 +58,7 @@ export function createSale({
saleDate,
saleLocation,
soldPriceMinor,
currency = 'USD',
currency = "USD",
platformFeesMinor = 0,
paymentProcessingFeesMinor = 0,
shippingCostMinor = 0,
@@ -69,42 +69,55 @@ export function createSale({
notes,
}) {
return {
sellerId: requiredText(sellerId, 'Seller ID'),
saleDate: requireIsoDate(saleDate, 'Sale date'),
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
sellerId: requiredText(sellerId, "Seller ID"),
saleDate: requireIsoDate(saleDate, "Sale date"),
...(typeof saleLocation === "string" && saleLocation.trim().length > 0
? { saleLocation: saleLocation.trim() }
: {}),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
currency: requireCurrency(currency),
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
platformFeesMinor: requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
),
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
paymentProcessingFeesMinor,
'Payment processing fees',
"Payment processing fees",
),
shippingCostMinor: requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost'),
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, 'Taxes'),
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees'),
...(typeof buyerName === 'string' && buyerName.trim().length > 0
shippingCostMinor: requireNonNegativeMinorUnit(
shippingCostMinor,
"Shipping cost",
),
taxesMinor: requireNonNegativeMinorUnit(taxesMinor, "Taxes"),
otherFeesMinor: requireNonNegativeMinorUnit(otherFeesMinor, "Other fees"),
...(typeof buyerName === "string" && buyerName.trim().length > 0
? { buyerName: buyerName.trim() }
: {}),
...(typeof buyerContact === 'string' && buyerContact.trim().length > 0
...(typeof buyerContact === "string" && buyerContact.trim().length > 0
? { buyerContact: buyerContact.trim() }
: {}),
...(typeof notes === 'string' && notes.trim().length > 0 ? { notes: notes.trim() } : {}),
...(typeof notes === "string" && notes.trim().length > 0
? { notes: notes.trim() }
: {}),
};
}
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
return {
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, "Sold price"),
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, "Cost basis"),
};
}
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
export function createSaleItem({
saleId,
inventoryItemId,
soldPriceMinor,
costBasisMinor,
}) {
return {
saleId: requiredText(saleId, 'Sale ID'),
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
saleId: requiredText(saleId, "Sale ID"),
inventoryItemId: requiredText(inventoryItemId, "Inventory item ID"),
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
};
}
@@ -117,15 +130,23 @@ export function calculateNetSaleProceeds({
taxesMinor = 0,
otherFeesMinor = 0,
}) {
const salePrice = requirePositiveMinorUnit(soldPriceMinor, 'Sold price');
const platformFees = requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees');
const salePrice = requirePositiveMinorUnit(soldPriceMinor, "Sold price");
const platformFees = requireNonNegativeMinorUnit(
platformFeesMinor,
"Platform fees",
);
const processingFees = requireNonNegativeMinorUnit(
paymentProcessingFeesMinor,
'Payment processing fees',
"Payment processing fees",
);
const shipping = requireNonNegativeMinorUnit(shippingCostMinor, 'Shipping cost');
const taxes = requireNonNegativeMinorUnit(taxesMinor, 'Taxes');
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, 'Other fees');
const shipping = requireNonNegativeMinorUnit(
shippingCostMinor,
"Shipping cost",
);
const taxes = requireNonNegativeMinorUnit(taxesMinor, "Taxes");
const otherFees = requireNonNegativeMinorUnit(otherFeesMinor, "Other fees");
return salePrice - platformFees - processingFees - shipping - taxes - otherFees;
return (
salePrice - platformFees - processingFees - shipping - taxes - otherFees
);
}
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, desc, eq, inArray } from "drizzle-orm";
import {
inventoryItems,
@@ -6,14 +6,20 @@ import {
saleItems,
sales,
securityAuditEvents,
} from '../../../infrastructure/database/schema.js';
} from "../../../infrastructure/database/schema.js";
export function createSaleRepository(db) {
return {
async createForInventoryItem({ sale, inventoryItemId, costBasisMinor }) {
return this.createForInventoryItems({
sale,
items: [{ inventoryItemId, soldPriceMinor: sale.soldPriceMinor, costBasisMinor }],
items: [
{
inventoryItemId,
soldPriceMinor: sale.soldPriceMinor,
costBasisMinor,
},
],
});
},
@@ -23,33 +29,57 @@ export function createSaleRepository(db) {
.select({ id: inventoryItems.id, status: inventoryItems.status })
.from(inventoryItems)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
eq(products.sellerId, sale.sellerId),
));
.where(
and(
inArray(
inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(products.sellerId, sale.sellerId),
),
);
if (ownedInventoryItems.length !== items.length) return { status: 'NOT_FOUND' };
const unavailableItem = ownedInventoryItems.find((item) => item.status !== 'ACTIVE');
if (ownedInventoryItems.length !== items.length)
return { status: "NOT_FOUND" };
const unavailableItem = ownedInventoryItems.find(
(item) => item.status !== "ACTIVE",
);
if (unavailableItem) {
return { status: 'NOT_ACTIVE', inventoryStatus: unavailableItem.status };
return {
status: "NOT_ACTIVE",
inventoryStatus: unavailableItem.status,
};
}
const [createdSale] = await transaction.insert(sales).values(sale).returning();
const createdSaleItems = await transaction.insert(saleItems).values(items.map((item) => ({
saleId: createdSale.id,
...item,
}))).returning();
const [createdSale] = await transaction
.insert(sales)
.values(sale)
.returning();
const createdSaleItems = await transaction
.insert(saleItems)
.values(
items.map((item) => ({
saleId: createdSale.id,
...item,
})),
)
.returning();
const updatedInventoryItems = await transaction
.update(inventoryItems)
.set({ status: 'SOLD', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, items.map((item) => item.inventoryItemId)),
eq(inventoryItems.status, 'ACTIVE'),
))
.set({ status: "SOLD", updatedAt: new Date() })
.where(
and(
inArray(
inventoryItems.id,
items.map((item) => item.inventoryItemId),
),
eq(inventoryItems.status, "ACTIVE"),
),
)
.returning();
return {
status: 'CREATED',
status: "CREATED",
sale: createdSale,
saleItems: createdSaleItems,
saleItem: createdSaleItems[0],
@@ -69,7 +99,10 @@ export function createSaleRepository(db) {
})
.from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(eq(sales.sellerId, sellerId))
.orderBy(desc(sales.saleDate), desc(sales.createdAt));
@@ -83,7 +116,10 @@ export function createSaleRepository(db) {
productNames: [],
};
current.saleItems.push(row.saleItem);
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus });
current.inventoryItems.push({
id: row.saleItem.inventoryItemId,
status: row.inventoryStatus,
});
current.productNames.push(row.productName);
grouped.set(row.sale.id, current);
}
@@ -91,46 +127,69 @@ export function createSaleRepository(db) {
...sale,
saleItem: sale.saleItems[0],
inventoryItem: sale.inventoryItems[0],
productName: sale.productNames.join(', '),
productName: sale.productNames.join(", "),
}));
},
async voidForSeller({ saleId, sellerId, actorUserId, reason }) {
return db.transaction(async (transaction) => {
const saleRecords = await transaction
.select({ sale: sales, saleItem: saleItems, inventoryStatus: inventoryItems.status })
.select({
sale: sales,
saleItem: saleItems,
inventoryStatus: inventoryItems.status,
})
.from(sales)
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
.innerJoin(inventoryItems, eq(inventoryItems.id, saleItems.inventoryItemId))
.innerJoin(
inventoryItems,
eq(inventoryItems.id, saleItems.inventoryItemId),
)
.innerJoin(products, eq(products.id, inventoryItems.productId))
.where(and(eq(sales.id, saleId), eq(sales.sellerId, sellerId), eq(products.sellerId, sellerId)));
.where(
and(
eq(sales.id, saleId),
eq(sales.sellerId, sellerId),
eq(products.sellerId, sellerId),
),
);
if (!saleRecords.length) return { status: 'NOT_FOUND' };
if (saleRecords[0].sale.status === 'VOIDED') return { status: 'ALREADY_VOIDED' };
if (!saleRecords.length) return { status: "NOT_FOUND" };
if (saleRecords[0].sale.status === "VOIDED")
return { status: "ALREADY_VOIDED" };
const saleRecord = saleRecords[0];
const inventoryItemIds = saleRecords.map(({ saleItem }) => saleItem.inventoryItemId);
const inventoryItemIds = saleRecords.map(
({ saleItem }) => saleItem.inventoryItemId,
);
const [updatedSale] = await transaction
.update(sales)
.set({ status: 'VOIDED', voidReason: reason, voidedAt: new Date(), updatedAt: new Date() })
.where(and(eq(sales.id, saleId), eq(sales.status, 'COMPLETED')))
.set({
status: "VOIDED",
voidReason: reason,
voidedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(sales.id, saleId), eq(sales.status, "COMPLETED")))
.returning();
const [updatedInventoryItem] = await transaction
.update(inventoryItems)
.set({ status: 'ACTIVE', updatedAt: new Date() })
.where(and(
inArray(inventoryItems.id, inventoryItemIds),
eq(inventoryItems.status, 'SOLD'),
))
.set({ status: "ACTIVE", updatedAt: new Date() })
.where(
and(
inArray(inventoryItems.id, inventoryItemIds),
eq(inventoryItems.status, "SOLD"),
),
)
.returning();
await transaction.insert(securityAuditEvents).values({
userId: actorUserId,
event: 'SALE_VOIDED',
event: "SALE_VOIDED",
metadata: { saleId, inventoryItemIds, reason },
});
return {
status: 'VOIDED',
status: "VOIDED",
sale: updatedSale,
saleItems: saleRecords.map(({ saleItem }) => saleItem),
saleItem: saleRecord.saleItem,
@@ -139,4 +198,4 @@ export function createSaleRepository(db) {
});
},
};
}
}
@@ -1,47 +1,64 @@
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
import { toAdminSaleDto } from "@pokemon-inventory/contracts/sale";
import {
createMultiItemSaleSchema,
createSaleSchema,
voidSaleSchema,
} from "@pokemon-inventory/validation/sale";
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function validationError(reply, result) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
code: "VALIDATION_ERROR",
message: "Request validation failed.",
details: result.error.issues.map(({ message, path }) => ({
message,
path,
})),
},
});
}
async function defaultAuthenticateAdmin() {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
}
export async function registerAdminSaleRoutes(app, options) {
const {
authenticateAdmin = defaultAuthenticateAdmin,
authorizeUnsafeRequest,
csrfCookieName = 'pokemon_inventory_csrf',
sessionCookieName = 'pokemon_inventory_session',
csrfCookieName = "pokemon_inventory_csrf",
sessionCookieName = "pokemon_inventory_session",
saleService,
} = options;
app.addHook('preHandler', async (request) => {
app.addHook("preHandler", async (request) => {
const admin = await authenticateAdmin(request);
if (!admin?.sellerId) {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
throw createApplicationError(
"UNAUTHORIZED",
"Authentication is required.",
401,
);
}
request.admin = admin;
});
app.post('/sales', async (request, reply) => {
app.post("/sales", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
request.headers["x-csrf-token"],
);
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
const schema = Array.isArray(request.body?.items)
? createMultiItemSaleSchema
: createSaleSchema;
const result = schema.safeParse(request.body);
if (!result.success) {
@@ -56,14 +73,16 @@ export async function registerAdminSaleRoutes(app, options) {
return reply.code(201).send({ data: toAdminSaleDto(sale) });
});
app.get('/sales', async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
app.get("/sales", async (request) => ({
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(
toAdminSaleDto,
),
}));
app.post('/sales/:id/void', async (request, reply) => {
app.post("/sales/:id/void", async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
request.headers["x-csrf-token"],
);
const result = voidSaleSchema.safeParse(request.body);
if (!result.success) return validationError(reply, result);
@@ -76,4 +95,4 @@ export async function registerAdminSaleRoutes(app, options) {
});
return reply.code(200).send({ data: toAdminSaleDto(sale) });
});
}
}
@@ -1,35 +1,62 @@
import { calculateNetSaleProceeds, createSale, validateSaleItem } from '../domain/sale.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import {
calculateNetSaleProceeds,
createSale,
validateSaleItem,
} from "../domain/sale.js";
import { createApplicationError } from "../../../shared/errors/application-error.js";
export function createSaleService({ saleRepository }) {
return {
async create({ sellerId, inventoryItemId, costBasisMinor, items, ...input }) {
const saleItems = items ?? [{ inventoryItemId, soldPriceMinor: input.soldPriceMinor, costBasisMinor }];
async create({
sellerId,
inventoryItemId,
costBasisMinor,
items,
...input
}) {
const saleItems = items ?? [
{
inventoryItemId,
soldPriceMinor: input.soldPriceMinor,
costBasisMinor,
},
];
const totalSoldPriceMinor = items
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
: input.soldPriceMinor;
const sale = createSale({ sellerId, ...input, soldPriceMinor: totalSoldPriceMinor });
const sale = createSale({
sellerId,
...input,
soldPriceMinor: totalSoldPriceMinor,
});
const validatedItems = saleItems.map((item) => ({
inventoryItemId: item.inventoryItemId,
soldPriceMinor: validateSaleItem(item).soldPriceMinor,
costBasisMinor: validateSaleItem(item).costBasisMinor,
}));
const result = items
? await saleRepository.createForInventoryItems({ sale, items: validatedItems })
? await saleRepository.createForInventoryItems({
sale,
items: validatedItems,
})
: await saleRepository.createForInventoryItem({
sale,
inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor,
});
sale,
inventoryItemId,
costBasisMinor: validatedItems[0].costBasisMinor,
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"INVENTORY_NOT_FOUND",
"Inventory item was not found.",
404,
);
}
if (result.status === 'NOT_ACTIVE') {
if (result.status === "NOT_ACTIVE") {
throw createApplicationError(
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
'Inventory item is not available for sale.',
"INVENTORY_NOT_AVAILABLE_FOR_SALE",
"Inventory item is not available for sale.",
409,
);
}
@@ -43,8 +70,12 @@ export function createSaleService({ saleRepository }) {
},
async void({ saleId, sellerId, actorUserId, reason }) {
if (typeof reason !== 'string' || reason.trim().length < 3) {
throw createApplicationError('SALE_VOID_REASON_REQUIRED', 'A void reason is required.', 400);
if (typeof reason !== "string" || reason.trim().length < 3) {
throw createApplicationError(
"SALE_VOID_REASON_REQUIRED",
"A void reason is required.",
400,
);
}
const result = await saleRepository.voidForSeller({
@@ -53,11 +84,19 @@ export function createSaleService({ saleRepository }) {
actorUserId,
reason: reason.trim(),
});
if (result.status === 'NOT_FOUND') {
throw createApplicationError('SALE_NOT_FOUND', 'Sale was not found.', 404);
if (result.status === "NOT_FOUND") {
throw createApplicationError(
"SALE_NOT_FOUND",
"Sale was not found.",
404,
);
}
if (result.status === 'ALREADY_VOIDED') {
throw createApplicationError('SALE_ALREADY_VOIDED', 'Sale is already voided.', 409);
if (result.status === "ALREADY_VOIDED") {
throw createApplicationError(
"SALE_ALREADY_VOIDED",
"Sale is already voided.",
409,
);
}
return withFinancialSummary(result);
@@ -66,11 +105,13 @@ export function createSaleService({ saleRepository }) {
}
function withFinancialSummary(result) {
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
const saleItems = result.saleItems ?? [result.saleItem];
return {
...result,
netProceedsMinor,
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
profitMinor:
netProceedsMinor -
saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
};
}
}
@@ -1,5 +1,5 @@
function requiredString(value, fieldName) {
if (typeof value !== 'string') {
if (typeof value !== "string") {
throw new Error(`${fieldName} must be a string.`);
}
@@ -7,16 +7,16 @@ function requiredString(value, fieldName) {
}
export function normalizeSearchQuery(value) {
return requiredString(value, 'Search query')
return requiredString(value, "Search query")
.trim()
.normalize('NFKC')
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.normalize("NFKC")
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
.replace(/\s+/g, " ")
.toLowerCase();
}
export function createSearchQuery({
query = '',
query = "",
category,
conditionId,
minPriceMinor,
@@ -24,54 +24,68 @@ export function createSearchQuery({
page = 1,
limit = 20,
includeSold = false,
sort = 'recent',
sort = "recent",
}) {
const text = normalizeSearchQuery(query);
if (!Number.isInteger(page) || page < 1) {
throw new Error('page must be >= 1');
throw new Error("page must be >= 1");
}
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('limit must be between 1 and 100');
}
if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) {
throw new Error('minPriceMinor must be a non-negative integer');
}
if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) {
throw new Error('maxPriceMinor must be a non-negative integer');
throw new Error("limit must be between 1 and 100");
}
if (
typeof minPriceMinor !== 'undefined' &&
typeof maxPriceMinor !== 'undefined' &&
minPriceMinor > maxPriceMinor
typeof minPriceMinor !== "undefined" &&
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
) {
throw new Error('minPriceMinor must be <= maxPriceMinor');
throw new Error("minPriceMinor must be a non-negative integer");
}
if (!['recent', 'price_low', 'price_high'].includes(sort)) {
throw new Error('sort must be recent, price_low, or price_high');
if (
typeof maxPriceMinor !== "undefined" &&
(!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)
) {
throw new Error("maxPriceMinor must be a non-negative integer");
}
if (
typeof minPriceMinor !== "undefined" &&
typeof maxPriceMinor !== "undefined" &&
minPriceMinor > maxPriceMinor
) {
throw new Error("minPriceMinor must be <= maxPriceMinor");
}
if (!["recent", "price_low", "price_high"].includes(sort)) {
throw new Error("sort must be recent, price_low, or price_high");
}
return {
text,
...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}),
...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}),
...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}),
...(typeof category === "string" && category.trim().length > 0
? { category: category.trim() }
: {}),
...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() }
: {}),
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page,
limit,
includeSold,
...(sort !== 'recent' ? { sort } : {}),
...(sort !== "recent" ? { sort } : {}),
};
}
export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) {
export function buildSearchFilters({
includeSold = false,
status = "AVAILABLE",
sort = ["recently_added"],
}) {
return {
status: includeSold ? 'ALL' : status,
status: includeSold ? "ALL" : status,
includeSold,
sort,
};
@@ -1,13 +1,17 @@
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { createApplicationError } from "../../../shared/errors/application-error.js";
function coerceNumber(value, fieldName, fallback) {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number.parseInt(String(value), 10);
if (Number.isNaN(parsed) || String(parsed) !== String(value)) {
throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400);
throw createApplicationError(
"INVALID_SEARCH_FILTER",
`${fieldName} must be an integer.`,
400,
);
}
return parsed;
}
@@ -15,11 +19,11 @@ function coerceNumber(value, fieldName, fallback) {
export async function registerPublicSearchRoutes(app, options) {
const { searchService } = options;
app.get('/public/search', async (request) => {
if (!searchService || typeof searchService.searchPublic !== 'function') {
app.get("/public/search", async (request) => {
if (!searchService || typeof searchService.searchPublic !== "function") {
throw createApplicationError(
'SEARCH_SERVICE_UNAVAILABLE',
'Public search is currently unavailable.',
"SEARCH_SERVICE_UNAVAILABLE",
"Public search is currently unavailable.",
503,
);
}
@@ -28,14 +32,24 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query,
category: request.query.category,
conditionId: request.query.conditionId,
minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined),
maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined),
page: coerceNumber(request.query.page, 'page', 1),
limit: coerceNumber(request.query.limit, 'limit', 20),
includeSold: request.query.includeSold === 'true' || request.query.includeSold === true,
sort: request.query.sort ?? 'recent',
minPriceMinor: coerceNumber(
request.query.minPriceMinor,
"minPriceMinor",
undefined,
),
maxPriceMinor: coerceNumber(
request.query.maxPriceMinor,
"maxPriceMinor",
undefined,
),
page: coerceNumber(request.query.page, "page", 1),
limit: coerceNumber(request.query.limit, "limit", 20),
includeSold:
request.query.includeSold === "true" ||
request.query.includeSold === true,
sort: request.query.sort ?? "recent",
};
return { data: await searchService.searchPublic(filters) };
});
}
}