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:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user