Commit the working vertical slices for inventory image processing and the Sale workflow, including Sale history, audited voiding, multi-item persistence, admin UI, migrations, documentation, and tests.
This commit is contained in:
+2
-2
@@ -6,8 +6,8 @@ INITIAL_ADMIN_PASSWORD=
|
|||||||
INITIAL_SELLER_NAME=
|
INITIAL_SELLER_NAME=
|
||||||
MFA_ENCRYPTION_KEY=
|
MFA_ENCRYPTION_KEY=
|
||||||
PASSWORD_RESET_URL=http://localhost:5173/reset-password
|
PASSWORD_RESET_URL=http://localhost:5173/reset-password
|
||||||
SMTP_FROM=
|
SMTP_FROM=no-reply@example.test
|
||||||
SMTP_URL=
|
SMTP_URL=smtp://localhost:1025
|
||||||
POSTGRES_DB=pokemon_inventory
|
POSTGRES_DB=pokemon_inventory
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
POSTGRES_PORT=5433
|
POSTGRES_PORT=5433
|
||||||
|
|||||||
@@ -73,7 +73,15 @@ Integration tests reject database names not ending in `_test` and truncate their
|
|||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
After creating `.env` and setting the required local SMTP and MFA values, start the complete development stack from the repository root with:
|
After creating `.env`, set the required local SMTP and MFA values, then start the complete development stack from the repository root with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_URL=smtp://localhost:1025
|
||||||
|
SMTP_FROM=no-reply@example.test
|
||||||
|
MFA_ENCRYPTION_KEY=<output from: openssl rand -base64 32>
|
||||||
|
```
|
||||||
|
|
||||||
|
The MFA key must remain stable while enrolled MFA methods exist. Keep it in local secret storage and do not commit it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -132,8 +140,8 @@ After the development database is migrated, set `INITIAL_ADMIN_EMAIL`, `INITIAL_
|
|||||||
|
|
||||||
## Password Recovery
|
## Password Recovery
|
||||||
|
|
||||||
Set `SMTP_URL`, `SMTP_FROM`, and `PASSWORD_RESET_URL` in the local `.env` before starting the API. Password reset tokens are delivered only through SMTP, expire after one hour, are stored as hashes, and are single use.
|
Set `SMTP_URL`, `SMTP_FROM`, and `PASSWORD_RESET_URL` in the local `.env` before starting the API. For local development with an SMTP service listening on port `1025`, use `SMTP_URL=smtp://localhost:1025` and `SMTP_FROM=no-reply@example.test`. Password reset tokens are delivered only through SMTP, expire after one hour, are stored as hashes, and are single use.
|
||||||
|
|
||||||
## MFA Secret Protection
|
## MFA Secret Protection
|
||||||
|
|
||||||
Set `MFA_ENCRYPTION_KEY` to a base64-encoded 32-byte key before starting the API. Generate one with `openssl rand -base64 32`; keep it in secret management and retain it for as long as enrolled MFA methods must remain readable.
|
Set `MFA_ENCRYPTION_KEY` to a base64-encoded 32-byte key before starting the API. Generate one with `openssl rand -base64 32`, then paste the output after `MFA_ENCRYPTION_KEY=` in `.env`. A command that only runs `printf` displays the value but does not update `.env`. Keep the key in secret management and retain it for as long as enrolled MFA methods must remain readable.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { registerAdminProductRoutes } from '../modules/products/routes/admin-pro
|
|||||||
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js';
|
||||||
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-routes.js';
|
||||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
||||||
|
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
||||||
|
|
||||||
export function buildApp(dependencies) {
|
export function buildApp(dependencies) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
@@ -61,6 +62,10 @@ export function buildApp(dependencies) {
|
|||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: '/api/v1/admin',
|
||||||
});
|
});
|
||||||
|
app.register(registerAdminSaleRoutes, {
|
||||||
|
...dependencies,
|
||||||
|
prefix: '/api/v1/admin',
|
||||||
|
});
|
||||||
app.register(registerPublicSearchRoutes, {
|
app.register(registerPublicSearchRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: '/api/v1',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url)
|
|||||||
|
|
||||||
import { buildApp } from './build-app.js';
|
import { buildApp } from './build-app.js';
|
||||||
import { createDatabase } from '../infrastructure/database/client.js';
|
import { createDatabase } from '../infrastructure/database/client.js';
|
||||||
|
import { resolveProjectImageStorageRoot } from '../infrastructure/images/local-image-storage.js';
|
||||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||||
@@ -36,6 +37,8 @@ import { createPublicProductService } from '../modules/products/services/public-
|
|||||||
import { createSearchService } from '../modules/search/services/search-service.js';
|
import { createSearchService } from '../modules/search/services/search-service.js';
|
||||||
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
import { createSellerRepository } from '../modules/sellers/repositories/seller-repository.js';
|
||||||
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
import { createPublicSellerService } from '../modules/sellers/services/public-seller-service.js';
|
||||||
|
import { createSaleRepository } from '../modules/sales/repositories/sale-repository.js';
|
||||||
|
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
||||||
|
|
||||||
function parsePort(value) {
|
function parsePort(value) {
|
||||||
const port = Number.parseInt(value ?? '3000', 10);
|
const port = Number.parseInt(value ?? '3000', 10);
|
||||||
@@ -48,6 +51,7 @@ function parsePort(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const redis = await createRedisConnection();
|
const redis = await createRedisConnection();
|
||||||
const productRepository = createProductRepository(database.db);
|
const productRepository = createProductRepository(database.db);
|
||||||
@@ -58,6 +62,7 @@ async function startServer() {
|
|||||||
const listingRepository = createListingRepository(database.db);
|
const listingRepository = createListingRepository(database.db);
|
||||||
const sellerRepository = createSellerRepository(database.db);
|
const sellerRepository = createSellerRepository(database.db);
|
||||||
const conditionRepository = createConditionRepository(database.db);
|
const conditionRepository = createConditionRepository(database.db);
|
||||||
|
const saleRepository = createSaleRepository(database.db);
|
||||||
const userRepository = createUserRepository(database.db);
|
const userRepository = createUserRepository(database.db);
|
||||||
const sessionRepository = createSessionRepository(database.db);
|
const sessionRepository = createSessionRepository(database.db);
|
||||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||||
@@ -71,7 +76,7 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
const passwordResetNotifier = createPasswordResetNotifier();
|
const passwordResetNotifier = createPasswordResetNotifier();
|
||||||
const publicImageService = createPublicImageService({
|
const publicImageService = createPublicImageService({
|
||||||
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
|
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||||
inventoryImageRepository,
|
inventoryImageRepository,
|
||||||
});
|
});
|
||||||
const authenticationService = createAuthenticationService({
|
const authenticationService = createAuthenticationService({
|
||||||
@@ -108,11 +113,12 @@ async function startServer() {
|
|||||||
publicImageService,
|
publicImageService,
|
||||||
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }),
|
||||||
inventoryImageService: createInventoryImageService({
|
inventoryImageService: createInventoryImageService({
|
||||||
imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'),
|
imageStorage: createLocalImageStorage(imageStorageRoot),
|
||||||
imageDerivativeJobRepository,
|
imageDerivativeJobRepository,
|
||||||
inventoryImageRepository,
|
inventoryImageRepository,
|
||||||
}),
|
}),
|
||||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||||
|
saleService: createSaleService({ saleRepository }),
|
||||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
publicProductService: createPublicProductService({ productRepository }),
|
publicProductService: createPublicProductService({ productRepository }),
|
||||||
|
|||||||
@@ -297,4 +297,46 @@ export const listings = pgTable('listings', {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||||
index('listings_status_index').on(table.status),
|
index('listings_status_index').on(table.status),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const sales = pgTable('sales', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
sellerId: uuid('seller_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => sellers.id),
|
||||||
|
status: text('status').default('COMPLETED').notNull(),
|
||||||
|
saleDate: text('sale_date').notNull(),
|
||||||
|
saleLocation: text('sale_location'),
|
||||||
|
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||||
|
currency: text('currency').notNull(),
|
||||||
|
platformFeesMinor: integer('platform_fees_minor').default(0).notNull(),
|
||||||
|
paymentProcessingFeesMinor: integer('payment_processing_fees_minor').default(0).notNull(),
|
||||||
|
shippingCostMinor: integer('shipping_cost_minor').default(0).notNull(),
|
||||||
|
taxesMinor: integer('taxes_minor').default(0).notNull(),
|
||||||
|
otherFeesMinor: integer('other_fees_minor').default(0).notNull(),
|
||||||
|
buyerName: text('buyer_name'),
|
||||||
|
buyerContact: text('buyer_contact'),
|
||||||
|
notes: text('notes'),
|
||||||
|
voidReason: text('void_reason'),
|
||||||
|
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||||
|
...timestamps,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saleItems = pgTable(
|
||||||
|
'sale_items',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
saleId: uuid('sale_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => sales.id),
|
||||||
|
inventoryItemId: uuid('inventory_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => inventoryItems.id),
|
||||||
|
soldPriceMinor: integer('sold_price_minor').notNull(),
|
||||||
|
costBasisMinor: integer('cost_basis_minor').notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('sale_items_inventory_item_id_unique').on(table.inventoryItemId),
|
||||||
|
index('sale_items_sale_id_index').on(table.saleId),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { createImageStorageContract } from './image-storage.js';
|
import { createImageStorageContract } from './image-storage.js';
|
||||||
|
|
||||||
|
function findProjectRoot(startDir) {
|
||||||
|
let currentDir = startDir;
|
||||||
|
|
||||||
|
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||||
|
if (existsSync(path.join(currentDir, 'package.json')) && existsSync(path.join(currentDir, 'AGENTS.md'))) {
|
||||||
|
return currentDir;
|
||||||
|
}
|
||||||
|
currentDir = path.dirname(currentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return startDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectImageStorageRoot(rootDirectory = '.local/image-storage', sourceUrl = import.meta.url) {
|
||||||
|
const projectRoot = findProjectRoot(path.dirname(fileURLToPath(sourceUrl)));
|
||||||
|
|
||||||
|
if (typeof rootDirectory !== 'string' || rootDirectory.length === 0) {
|
||||||
|
return path.join(projectRoot, '.local/image-storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isAbsolute(rootDirectory)) {
|
||||||
|
return rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(projectRoot, rootDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
function safePath(rootDirectory, key) {
|
function safePath(rootDirectory, key) {
|
||||||
if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) {
|
if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) {
|
||||||
throw new Error('Image storage key must be a relative non-empty path.');
|
throw new Error('Image storage key must be a relative non-empty path.');
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ function requireNonNegativeMinorUnit(value, fieldName) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireIsoDate(value, fieldName) {
|
||||||
|
const date = requiredText(value, fieldName);
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`${fieldName} must be a valid ISO date.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
throw new Error(`${fieldName} must be a valid ISO date.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCurrency(value) {
|
||||||
|
const currency = requiredText(value, 'Currency').toUpperCase();
|
||||||
|
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||||
|
throw new Error('Currency must be a three-letter ISO currency code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSale({
|
export function createSale({
|
||||||
sellerId,
|
sellerId,
|
||||||
saleDate,
|
saleDate,
|
||||||
@@ -39,12 +70,12 @@ export function createSale({
|
|||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||||
saleDate: requiredText(saleDate, 'Sale date'),
|
saleDate: requireIsoDate(saleDate, 'Sale date'),
|
||||||
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
|
...(typeof saleLocation === 'string' && saleLocation.trim().length > 0
|
||||||
? { saleLocation: saleLocation.trim() }
|
? { saleLocation: saleLocation.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||||
currency: requiredText(currency, 'Currency'),
|
currency: requireCurrency(currency),
|
||||||
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
|
platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'),
|
||||||
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
paymentProcessingFeesMinor: requireNonNegativeMinorUnit(
|
||||||
paymentProcessingFeesMinor,
|
paymentProcessingFeesMinor,
|
||||||
@@ -63,17 +94,18 @@ export function createSale({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSaleItem({
|
export function validateSaleItem({ soldPriceMinor, costBasisMinor }) {
|
||||||
saleId,
|
return {
|
||||||
inventoryItemId,
|
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
||||||
soldPriceMinor,
|
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
|
||||||
costBasisMinor,
|
};
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) {
|
||||||
return {
|
return {
|
||||||
saleId: requiredText(saleId, 'Sale ID'),
|
saleId: requiredText(saleId, 'Sale ID'),
|
||||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||||
soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'),
|
...validateSaleItem({ soldPriceMinor, costBasisMinor }),
|
||||||
costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
inventoryItems,
|
||||||
|
products,
|
||||||
|
saleItems,
|
||||||
|
sales,
|
||||||
|
securityAuditEvents,
|
||||||
|
} 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 }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createForInventoryItems({ sale, items }) {
|
||||||
|
return db.transaction(async (transaction) => {
|
||||||
|
const ownedInventoryItems = await transaction
|
||||||
|
.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),
|
||||||
|
));
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'CREATED',
|
||||||
|
sale: createdSale,
|
||||||
|
saleItems: createdSaleItems,
|
||||||
|
saleItem: createdSaleItems[0],
|
||||||
|
inventoryItems: updatedInventoryItems,
|
||||||
|
inventoryItem: updatedInventoryItems[0],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listBySellerId(sellerId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
sale: sales,
|
||||||
|
saleItem: saleItems,
|
||||||
|
inventoryStatus: inventoryItems.status,
|
||||||
|
productName: products.name,
|
||||||
|
})
|
||||||
|
.from(sales)
|
||||||
|
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||||
|
.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));
|
||||||
|
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = grouped.get(row.sale.id) ?? {
|
||||||
|
sale: row.sale,
|
||||||
|
saleItems: [],
|
||||||
|
inventoryItems: [],
|
||||||
|
productNames: [],
|
||||||
|
};
|
||||||
|
current.saleItems.push(row.saleItem);
|
||||||
|
current.inventoryItems.push({ id: row.saleItem.inventoryItemId, status: row.inventoryStatus });
|
||||||
|
current.productNames.push(row.productName);
|
||||||
|
grouped.set(row.sale.id, current);
|
||||||
|
}
|
||||||
|
return [...grouped.values()].map((sale) => ({
|
||||||
|
...sale,
|
||||||
|
saleItem: sale.saleItems[0],
|
||||||
|
inventoryItem: sale.inventoryItems[0],
|
||||||
|
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 })
|
||||||
|
.from(sales)
|
||||||
|
.innerJoin(saleItems, eq(saleItems.saleId, sales.id))
|
||||||
|
.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)));
|
||||||
|
|
||||||
|
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 [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')))
|
||||||
|
.returning();
|
||||||
|
const [updatedInventoryItem] = await transaction
|
||||||
|
.update(inventoryItems)
|
||||||
|
.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',
|
||||||
|
metadata: { saleId, inventoryItemIds, reason },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'VOIDED',
|
||||||
|
sale: updatedSale,
|
||||||
|
saleItems: saleRecords.map(({ saleItem }) => saleItem),
|
||||||
|
saleItem: saleRecord.saleItem,
|
||||||
|
inventoryItem: updatedInventoryItem,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { toAdminSaleDto } from '@pokemon-inventory/contracts/sale';
|
||||||
|
import { createMultiItemSaleSchema, createSaleSchema, voidSaleSchema } from '@pokemon-inventory/validation/sale';
|
||||||
|
|
||||||
|
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 })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultAuthenticateAdmin() {
|
||||||
|
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',
|
||||||
|
saleService,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
app.addHook('preHandler', async (request) => {
|
||||||
|
const admin = await authenticateAdmin(request);
|
||||||
|
|
||||||
|
if (!admin?.sellerId) {
|
||||||
|
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.admin = admin;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/sales', async (request, reply) => {
|
||||||
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers['x-csrf-token'],
|
||||||
|
);
|
||||||
|
const schema = Array.isArray(request.body?.items) ? createMultiItemSaleSchema : createSaleSchema;
|
||||||
|
const result = schema.safeParse(request.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return validationError(reply, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sale = await saleService.create({
|
||||||
|
...result.data,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send({ data: toAdminSaleDto(sale) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sales', async (request) => ({
|
||||||
|
data: (await saleService.list({ sellerId: request.admin.sellerId })).map(toAdminSaleDto),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/sales/:id/void', async (request, reply) => {
|
||||||
|
await authorizeUnsafeRequest?.(
|
||||||
|
request.cookies[sessionCookieName],
|
||||||
|
request.headers['x-csrf-token'],
|
||||||
|
);
|
||||||
|
const result = voidSaleSchema.safeParse(request.body);
|
||||||
|
if (!result.success) return validationError(reply, result);
|
||||||
|
|
||||||
|
const sale = await saleService.void({
|
||||||
|
saleId: request.params.id,
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
actorUserId: request.admin.userId,
|
||||||
|
reason: result.data.reason,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ data: toAdminSaleDto(sale) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 }];
|
||||||
|
const totalSoldPriceMinor = items
|
||||||
|
? saleItems.reduce((total, item) => total + item.soldPriceMinor, 0)
|
||||||
|
: input.soldPriceMinor;
|
||||||
|
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.createForInventoryItem({
|
||||||
|
sale,
|
||||||
|
inventoryItemId,
|
||||||
|
costBasisMinor: validatedItems[0].costBasisMinor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 'NOT_FOUND') {
|
||||||
|
throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'NOT_ACTIVE') {
|
||||||
|
throw createApplicationError(
|
||||||
|
'INVENTORY_NOT_AVAILABLE_FOR_SALE',
|
||||||
|
'Inventory item is not available for sale.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withFinancialSummary(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
async list({ sellerId }) {
|
||||||
|
const sales = await saleRepository.listBySellerId(sellerId);
|
||||||
|
return sales.map(withFinancialSummary);
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saleRepository.voidForSeller({
|
||||||
|
saleId,
|
||||||
|
sellerId,
|
||||||
|
actorUserId,
|
||||||
|
reason: reason.trim(),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withFinancialSummary(result);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFinancialSummary(result) {
|
||||||
|
const netProceedsMinor = calculateNetSaleProceeds(result.sale);
|
||||||
|
const saleItems = result.saleItems ?? [result.saleItem];
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
netProceedsMinor,
|
||||||
|
profitMinor: netProceedsMinor - saleItems.reduce((total, item) => total + item.costBasisMinor, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
|
|||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
||||||
import { createLocalImageStorage } from '../../src/infrastructure/images/local-image-storage.js';
|
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../src/infrastructure/images/local-image-storage.js';
|
||||||
|
|
||||||
test('local image storage round-trips and deletes binary data', async () => {
|
test('local image storage round-trips and deletes binary data', async () => {
|
||||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||||
@@ -40,6 +41,16 @@ test('local image storage rejects traversal and duplicate writes', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('relative storage roots resolve from the project root regardless of the process cwd', () => {
|
||||||
|
const apiSource = new URL('../../src/app/server.js', import.meta.url);
|
||||||
|
const workerSource = new URL('../../../worker/src/worker-runtime.js', import.meta.url);
|
||||||
|
const expectedRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||||
|
|
||||||
|
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', apiSource), path.join(expectedRoot, '.local/image-storage'));
|
||||||
|
assert.equal(resolveProjectImageStorageRoot('.local/image-storage', workerSource), path.join(expectedRoot, '.local/image-storage'));
|
||||||
|
assert.equal(resolveProjectImageStorageRoot('/tmp/custom-image-storage', apiSource), '/tmp/custom-image-storage');
|
||||||
|
});
|
||||||
|
|
||||||
test('image storage contracts reject incomplete adapters', () => {
|
test('image storage contracts reject incomplete adapters', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createImageStorageContract({ put() {} }),
|
() => createImageStorageContract({ put() {} }),
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createSaleService } from '../../src/modules/sales/services/sale-service.js';
|
||||||
|
|
||||||
|
function validInput(overrides = {}) {
|
||||||
|
return {
|
||||||
|
sellerId: 'seller-1',
|
||||||
|
inventoryItemId: 'inventory-1',
|
||||||
|
saleDate: '2026-09-09',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
costBasisMinor: 1200,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates a sale through the repository with validated money values', async () => {
|
||||||
|
let received;
|
||||||
|
const service = createSaleService({
|
||||||
|
saleRepository: {
|
||||||
|
async createForInventoryItem(input) {
|
||||||
|
received = input;
|
||||||
|
return {
|
||||||
|
status: 'CREATED',
|
||||||
|
sale: {
|
||||||
|
id: 'sale-1',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
platformFeesMinor: 100,
|
||||||
|
paymentProcessingFeesMinor: 0,
|
||||||
|
shippingCostMinor: 0,
|
||||||
|
taxesMinor: 0,
|
||||||
|
otherFeesMinor: 0,
|
||||||
|
},
|
||||||
|
saleItem: { costBasisMinor: 1200 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await service.create(validInput()), {
|
||||||
|
status: 'CREATED',
|
||||||
|
sale: {
|
||||||
|
id: 'sale-1',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
platformFeesMinor: 100,
|
||||||
|
paymentProcessingFeesMinor: 0,
|
||||||
|
shippingCostMinor: 0,
|
||||||
|
taxesMinor: 0,
|
||||||
|
otherFeesMinor: 0,
|
||||||
|
},
|
||||||
|
saleItem: { costBasisMinor: 1200 },
|
||||||
|
netProceedsMinor: 2399,
|
||||||
|
profitMinor: 1199,
|
||||||
|
});
|
||||||
|
assert.equal(received.sale.sellerId, 'seller-1');
|
||||||
|
assert.equal(received.sale.soldPriceMinor, 2499);
|
||||||
|
assert.equal(received.costBasisMinor, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists seller sales with derived net proceeds and profit', async () => {
|
||||||
|
const service = createSaleService({
|
||||||
|
saleRepository: {
|
||||||
|
async listBySellerId(sellerId) {
|
||||||
|
assert.equal(sellerId, 'seller-1');
|
||||||
|
return [{
|
||||||
|
sale: { soldPriceMinor: 3000, shippingCostMinor: 200 },
|
||||||
|
saleItem: { costBasisMinor: 1500 },
|
||||||
|
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sale] = await service.list({ sellerId: 'seller-1' });
|
||||||
|
assert.equal(sale.netProceedsMinor, 2800);
|
||||||
|
assert.equal(sale.profitMinor, 1300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voids an owned sale with a required reason', async () => {
|
||||||
|
let received;
|
||||||
|
const service = createSaleService({
|
||||||
|
saleRepository: {
|
||||||
|
async voidForSeller(input) {
|
||||||
|
received = input;
|
||||||
|
return {
|
||||||
|
status: 'VOIDED',
|
||||||
|
sale: { id: 'sale-1', soldPriceMinor: 3000 },
|
||||||
|
saleItem: { costBasisMinor: 1500 },
|
||||||
|
inventoryItem: { id: 'inventory-1', status: 'ACTIVE' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.void({
|
||||||
|
saleId: 'sale-1',
|
||||||
|
sellerId: 'seller-1',
|
||||||
|
actorUserId: 'user-1',
|
||||||
|
reason: ' Duplicate sale ',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(received.reason, 'Duplicate sale');
|
||||||
|
assert.equal(result.inventoryItem.status, 'ACTIVE');
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.void({ saleId: 'sale-1', sellerId: 'seller-1', actorUserId: 'user-1', reason: 'x' }),
|
||||||
|
(error) => error.code === 'SALE_VOID_REASON_REQUIRED' && error.statusCode === 400,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates one sale containing multiple inventory items', async () => {
|
||||||
|
let received;
|
||||||
|
const service = createSaleService({
|
||||||
|
saleRepository: {
|
||||||
|
async createForInventoryItems(input) {
|
||||||
|
received = input;
|
||||||
|
return {
|
||||||
|
status: 'CREATED',
|
||||||
|
sale: { id: 'sale-2', soldPriceMinor: 3000 },
|
||||||
|
saleItems: input.items,
|
||||||
|
saleItem: input.items[0],
|
||||||
|
inventoryItems: input.items.map(({ inventoryItemId }) => ({ id: inventoryItemId, status: 'SOLD' })),
|
||||||
|
inventoryItem: { id: input.items[0].inventoryItemId, status: 'SOLD' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
sellerId: 'seller-1',
|
||||||
|
saleDate: '2026-09-09',
|
||||||
|
items: [
|
||||||
|
{ inventoryItemId: 'inventory-1', soldPriceMinor: 1200, costBasisMinor: 500 },
|
||||||
|
{ inventoryItemId: 'inventory-2', soldPriceMinor: 1800, costBasisMinor: 700 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(received.sale.soldPriceMinor, 3000);
|
||||||
|
assert.equal(received.items.length, 2);
|
||||||
|
assert.equal(result.profitMinor, 1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps missing and already-sold inventory to application errors', async () => {
|
||||||
|
for (const result of [
|
||||||
|
{ status: 'NOT_FOUND', code: 'INVENTORY_NOT_FOUND', statusCode: 404 },
|
||||||
|
{ status: 'NOT_ACTIVE', code: 'INVENTORY_NOT_AVAILABLE_FOR_SALE', statusCode: 409 },
|
||||||
|
]) {
|
||||||
|
const service = createSaleService({
|
||||||
|
saleRepository: { async createForInventoryItem() { return result; } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.create(validInput()),
|
||||||
|
(error) => error.code === result.code && error.statusCode === result.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -54,4 +54,23 @@ test('rejects invalid sales and negative fee values', () => {
|
|||||||
}),
|
}),
|
||||||
/must be a non-negative integer minor unit/,
|
/must be a non-negative integer minor unit/,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => createSale({
|
||||||
|
sellerId: 'seller-id',
|
||||||
|
saleDate: '2026-02-30',
|
||||||
|
soldPriceMinor: 2000,
|
||||||
|
}),
|
||||||
|
/must be a valid ISO date/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => createSale({
|
||||||
|
sellerId: 'seller-id',
|
||||||
|
saleDate: '2026-09-08',
|
||||||
|
soldPriceMinor: 2000,
|
||||||
|
currency: 'US dollars',
|
||||||
|
}),
|
||||||
|
/must be a three-letter ISO currency code/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildApp } from '../../src/app/build-app.js';
|
||||||
|
|
||||||
|
const saleResult = {
|
||||||
|
sale: {
|
||||||
|
id: 'sale-1',
|
||||||
|
sellerId: 'seller-1',
|
||||||
|
saleDate: '2026-09-09',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
currency: 'USD',
|
||||||
|
platformFeesMinor: 0,
|
||||||
|
paymentProcessingFeesMinor: 0,
|
||||||
|
shippingCostMinor: 0,
|
||||||
|
taxesMinor: 0,
|
||||||
|
otherFeesMinor: 0,
|
||||||
|
},
|
||||||
|
saleItem: {
|
||||||
|
id: 'sale-item-1',
|
||||||
|
inventoryItemId: 'inventory-1',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
costBasisMinor: 1200,
|
||||||
|
},
|
||||||
|
inventoryItem: { id: 'inventory-1', status: 'SOLD' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||||
|
return buildApp({
|
||||||
|
authenticateAdmin,
|
||||||
|
authorizeUnsafeRequest,
|
||||||
|
saleService: {
|
||||||
|
async create(input) {
|
||||||
|
assert.equal(input.sellerId, 'seller-1');
|
||||||
|
return saleResult;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates a sale for the authenticated seller with CSRF protection', async (testContext) => {
|
||||||
|
let csrfChecks = 0;
|
||||||
|
const app = createTestApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||||
|
});
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/sales',
|
||||||
|
headers: { 'x-csrf-token': 'csrf-token' },
|
||||||
|
payload: {
|
||||||
|
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||||
|
costBasisMinor: 1200,
|
||||||
|
saleDate: '2026-09-09',
|
||||||
|
soldPriceMinor: 2499,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 201);
|
||||||
|
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||||
|
assert.equal(csrfChecks, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid sale requests and unauthenticated requests', async (testContext) => {
|
||||||
|
const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) });
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const invalidResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/sales',
|
||||||
|
payload: {
|
||||||
|
inventoryItemId: 'not-a-uuid',
|
||||||
|
costBasisMinor: -1,
|
||||||
|
saleDate: 'tomorrow',
|
||||||
|
soldPriceMinor: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(invalidResponse.statusCode, 400);
|
||||||
|
assert.equal(invalidResponse.json().error.code, 'VALIDATION_ERROR');
|
||||||
|
|
||||||
|
const unauthenticatedApp = createTestApp();
|
||||||
|
testContext.after(() => unauthenticatedApp.close());
|
||||||
|
const unauthenticatedResponse = await unauthenticatedApp.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/sales',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(unauthenticatedResponse.statusCode, 401);
|
||||||
|
assert.equal(unauthenticatedResponse.json().error.code, 'UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voids a sale with CSRF protection and actor identity', async (testContext) => {
|
||||||
|
let voidInput;
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => ({ userId: 'user-1', sellerId: 'seller-1' }),
|
||||||
|
authorizeUnsafeRequest: async () => {},
|
||||||
|
saleService: {
|
||||||
|
async void(input) {
|
||||||
|
voidInput = input;
|
||||||
|
return saleResult;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/sales/sale-1/void',
|
||||||
|
headers: { 'x-csrf-token': 'csrf-token' },
|
||||||
|
payload: { reason: 'Duplicate entry' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(voidInput.actorUserId, 'user-1');
|
||||||
|
assert.equal(response.json().data.inventoryItem.status, 'SOLD');
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ const images = [
|
|||||||
export default { title: 'Organisms/AdminImageManager', component: AdminImageManager };
|
export default { title: 'Organisms/AdminImageManager', component: AdminImageManager };
|
||||||
|
|
||||||
export const Completed = { args: { images, processingStatus: 'COMPLETED' } };
|
export const Completed = { args: { images, processingStatus: 'COMPLETED' } };
|
||||||
|
export const EmptyUpload = { args: { images: [], processingStatus: 'COMPLETED' } };
|
||||||
export const Processing = { args: { images, processingStatus: 'PROCESSING' } };
|
export const Processing = { args: { images, processingStatus: 'PROCESSING' } };
|
||||||
export const Failed = { args: { images, processingStatus: 'FAILED', errorMessage: 'Derivative generation failed.' } };
|
export const Failed = { args: { images, processingStatus: 'FAILED', errorMessage: 'Derivative generation failed.' } };
|
||||||
export const Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } };
|
export const Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const props = defineProps({
|
|||||||
errorMessage: { type: String, default: '' },
|
errorMessage: { type: String, default: '' },
|
||||||
isSaving: { type: Boolean, default: false },
|
isSaving: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['reorder', 'primary', 'alt-text', 'retry', 'delete']);
|
const emit = defineEmits(['upload', 'reorder', 'primary', 'alt-text', 'retry', 'delete']);
|
||||||
const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? '');
|
const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? '');
|
||||||
const isEditingAltText = ref(false);
|
const isEditingAltText = ref(false);
|
||||||
const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder));
|
const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||||
@@ -20,6 +20,12 @@ function saveAltText() {
|
|||||||
emit('alt-text', altText.value);
|
emit('alt-text', altText.value);
|
||||||
isEditingAltText.value = false;
|
isEditingAltText.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFileChange(event) {
|
||||||
|
const [file] = event.target.files;
|
||||||
|
if (file) emit('upload', file);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -38,6 +44,11 @@ function saveAltText() {
|
|||||||
<button class="button button--secondary" type="button" @click="emit('retry')">Retry processing</button>
|
<button class="button button--secondary" type="button" @click="emit('retry')">Retry processing</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-image-manager__upload">
|
||||||
|
<label class="fixture-label" for="admin-image-upload">Add image</label>
|
||||||
|
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" :disabled="isSaving" @change="handleFileChange" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ImageGallery :images="orderedImages" />
|
<ImageGallery :images="orderedImages" />
|
||||||
|
|
||||||
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
|
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
|
||||||
@@ -66,5 +77,6 @@ function saveAltText() {
|
|||||||
.admin-image-manager__header { justify-content: space-between; }
|
.admin-image-manager__header { justify-content: space-between; }
|
||||||
.admin-image-manager__header h2 { font-size: var(--text-xl); }
|
.admin-image-manager__header h2 { font-size: var(--text-xl); }
|
||||||
.admin-image-manager__controls { display: grid; gap: var(--space-4); }
|
.admin-image-manager__controls { display: grid; gap: var(--space-4); }
|
||||||
|
.admin-image-manager__upload { display: grid; gap: var(--space-2); }
|
||||||
.admin-image-manager__row--order { align-items: stretch; }
|
.admin-image-manager__row--order { align-items: stretch; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
retryImageProcessing,
|
retryImageProcessing,
|
||||||
setPrimaryImage,
|
setPrimaryImage,
|
||||||
updateImageAltText,
|
updateImageAltText,
|
||||||
|
uploadAdminImage,
|
||||||
} from '@/services/admin-image-service.js';
|
} from '@/services/admin-image-service.js';
|
||||||
|
|
||||||
function csrfToken() {
|
function csrfToken() {
|
||||||
@@ -35,12 +36,30 @@ export function useAdminImages(inventoryItemId) {
|
|||||||
finally { isSaving.value = false; }
|
finally { isSaving.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upload(file) {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const image = new Image();
|
||||||
|
image.src = objectUrl;
|
||||||
|
try {
|
||||||
|
await image.decode();
|
||||||
|
await apply((token) => uploadAdminImage(inventoryItemId, file, {
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
altText: file.name,
|
||||||
|
isPrimary: images.value.length === 0,
|
||||||
|
}, token));
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
images,
|
images,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
error,
|
error,
|
||||||
load,
|
load,
|
||||||
|
upload,
|
||||||
reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)),
|
reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)),
|
||||||
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
|
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
|
||||||
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
|
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { useAuth } from '@/composables/useAuth.js';
|
|||||||
import AdminListingStatus from '@/components/molecules/AdminListingStatus.vue';
|
import AdminListingStatus from '@/components/molecules/AdminListingStatus.vue';
|
||||||
import { createAdminInventory, createAdminProduct, listAdminCategories, listAdminConditions, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
|
import { createAdminInventory, createAdminProduct, listAdminCategories, listAdminConditions, listAdminInventory, listAdminProducts } from '@/services/admin-catalog-service.js';
|
||||||
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
|
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
|
||||||
|
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
||||||
|
|
||||||
const { principal } = useAuth();
|
const { principal } = useAuth();
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
const inventory = ref([]);
|
const inventory = ref([]);
|
||||||
const listings = ref([]);
|
const listings = ref([]);
|
||||||
|
const sales = ref([]);
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const conditions = ref([]);
|
const conditions = ref([]);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -22,6 +24,11 @@ const inventoryProductId = ref('');
|
|||||||
const conditionId = ref('');
|
const conditionId = ref('');
|
||||||
const listingInventoryId = ref('');
|
const listingInventoryId = ref('');
|
||||||
const askingPrice = ref('');
|
const askingPrice = ref('');
|
||||||
|
const saleInventoryId = ref('');
|
||||||
|
const soldPrice = ref('');
|
||||||
|
const costBasis = ref('');
|
||||||
|
const saleDate = ref(new Date().toISOString().slice(0, 10));
|
||||||
|
const voidReasons = ref({});
|
||||||
|
|
||||||
function csrfToken() {
|
function csrfToken() {
|
||||||
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||||
@@ -30,7 +37,7 @@ function csrfToken() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
[products.value, inventory.value, categories.value, conditions.value, listings.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings()]);
|
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales()]);
|
||||||
} catch (requestError) { error.value = requestError; }
|
} catch (requestError) { error.value = requestError; }
|
||||||
finally { isLoading.value = false; }
|
finally { isLoading.value = false; }
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,39 @@ async function transitionListing(action, id) {
|
|||||||
finally { isSaving.value = false; }
|
finally { isSaving.value = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recordSale() {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const sale = await createAdminSale({
|
||||||
|
inventoryItemId: saleInventoryId.value,
|
||||||
|
costBasisMinor: Number(costBasis.value),
|
||||||
|
saleDate: saleDate.value,
|
||||||
|
soldPriceMinor: Number(soldPrice.value),
|
||||||
|
}, csrfToken());
|
||||||
|
inventory.value = inventory.value.map((item) => item.id === sale.inventoryItem.id
|
||||||
|
? { ...item, status: sale.inventoryItem.status }
|
||||||
|
: item);
|
||||||
|
sales.value.unshift(sale);
|
||||||
|
saleInventoryId.value = '';
|
||||||
|
soldPrice.value = '';
|
||||||
|
costBasis.value = '';
|
||||||
|
} catch (requestError) { error.value = requestError; }
|
||||||
|
finally { isSaving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voidSale(saleId) {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const sale = await voidAdminSale(saleId, voidReasons.value[saleId] ?? '', csrfToken());
|
||||||
|
sales.value = sales.value.map((candidate) => candidate.id === sale.id ? sale : candidate);
|
||||||
|
inventory.value = inventory.value.map((item) => item.id === sale.inventoryItem.id
|
||||||
|
? { ...item, status: sale.inventoryItem.status }
|
||||||
|
: item);
|
||||||
|
voidReasons.value = { ...voidReasons.value, [saleId]: '' };
|
||||||
|
} catch (requestError) { error.value = requestError; }
|
||||||
|
finally { isSaving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -123,6 +163,21 @@ onMounted(load);
|
|||||||
<input id="listing-price" v-model="askingPrice" class="fixture-input" type="number" min="1" required />
|
<input id="listing-price" v-model="askingPrice" class="fixture-input" type="number" min="1" required />
|
||||||
<button class="button button--primary" type="submit" :disabled="isSaving">Create listing</button>
|
<button class="button button--primary" type="submit" :disabled="isSaving">Create listing</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form class="showcase-panel" @submit.prevent="recordSale">
|
||||||
|
<h2>Record sale</h2>
|
||||||
|
<label class="fixture-label" for="sale-inventory">Inventory copy</label>
|
||||||
|
<select id="sale-inventory" v-model="saleInventoryId" class="fixture-input" required>
|
||||||
|
<option value="" disabled>Select an active copy</option>
|
||||||
|
<option v-for="item in inventory.filter(({ status }) => status === 'ACTIVE')" :key="item.id" :value="item.id">{{ item.id }}</option>
|
||||||
|
</select>
|
||||||
|
<label class="fixture-label" for="sale-date">Sale date</label>
|
||||||
|
<input id="sale-date" v-model="saleDate" class="fixture-input" type="date" required />
|
||||||
|
<label class="fixture-label" for="sold-price">Sold price (cents)</label>
|
||||||
|
<input id="sold-price" v-model="soldPrice" class="fixture-input" type="number" min="1" required />
|
||||||
|
<label class="fixture-label" for="cost-basis">Cost basis (cents)</label>
|
||||||
|
<input id="cost-basis" v-model="costBasis" class="fixture-input" type="number" min="0" required />
|
||||||
|
<button class="button button--primary" type="submit" :disabled="isSaving">Record sale</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="admin-list" aria-labelledby="products-title">
|
<section class="admin-list" aria-labelledby="products-title">
|
||||||
@@ -140,5 +195,23 @@ onMounted(load);
|
|||||||
<p v-if="!listings.length && !isLoading" class="state state--empty">No listings yet.</p>
|
<p v-if="!listings.length && !isLoading" class="state state--empty">No listings yet.</p>
|
||||||
<div v-else class="admin-listing-stack"><AdminListingStatus v-for="listing in listings" :key="listing.id" :listing="listing" :is-saving="isSaving" @publish="transitionListing(publishAdminListing, listing.id)" @pending="transitionListing(markListingPending, listing.id)" /></div>
|
<div v-else class="admin-listing-stack"><AdminListingStatus v-for="listing in listings" :key="listing.id" :listing="listing" :is-saving="isSaving" @publish="transitionListing(publishAdminListing, listing.id)" @pending="transitionListing(markListingPending, listing.id)" /></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="admin-list" aria-labelledby="sales-title">
|
||||||
|
<h2 id="sales-title">Sale history</h2>
|
||||||
|
<p v-if="!sales.length && !isLoading" class="state state--empty">No sales recorded yet.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="sale in sales" :key="sale.id">
|
||||||
|
<strong>{{ sale.productName ?? sale.saleItem.inventoryItemId }}</strong>
|
||||||
|
· {{ sale.saleDate }}
|
||||||
|
· {{ sale.status }}
|
||||||
|
· Sold ${{ (sale.soldPriceMinor / 100).toFixed(2) }}
|
||||||
|
· Profit ${{ (sale.profitMinor / 100).toFixed(2) }}
|
||||||
|
<form v-if="sale.status !== 'VOIDED'" @submit.prevent="voidSale(sale.id)">
|
||||||
|
<label class="fixture-label" :for="`void-reason-${sale.id}`">Void reason</label>
|
||||||
|
<input :id="`void-reason-${sale.id}`" v-model="voidReasons[sale.id]" class="fixture-input" minlength="3" required />
|
||||||
|
<button class="button button--secondary" type="submit" :disabled="isSaving">Void sale</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAdminImages } from '@/composables/useAdminImages.js';
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const inventoryItemId = route.params.id;
|
const inventoryItemId = route.params.id;
|
||||||
const { images, isLoading, isSaving, error, load, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
|
const { images, isLoading, isSaving, error, load, upload, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
@@ -23,6 +23,7 @@ onMounted(load);
|
|||||||
v-else
|
v-else
|
||||||
:images="images"
|
:images="images"
|
||||||
:processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')"
|
:processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')"
|
||||||
|
@upload="upload"
|
||||||
@reorder="reorder($event.id, $event.sortOrder)"
|
@reorder="reorder($event.id, $event.sortOrder)"
|
||||||
@primary="setPrimary"
|
@primary="setPrimary"
|
||||||
@alt-text="updateAltText(images[0]?.id, $event)"
|
@alt-text="updateAltText(images[0]?.id, $event)"
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ export function deleteImage(imageId, csrfToken) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAdminImage(inventoryItemId, file, fields, csrfToken) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('width', String(fields.width));
|
||||||
|
formData.append('height', String(fields.height));
|
||||||
|
formData.append('altText', fields.altText ?? '');
|
||||||
|
formData.append('isPrimary', String(fields.isPrimary ?? false));
|
||||||
|
|
||||||
|
const response = await fetch(`${apiBaseUrl}/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'x-csrf-token': csrfToken },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Image upload is unavailable.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAdminImages(inventoryItemId) {
|
export async function listAdminImages(inventoryItemId) {
|
||||||
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
|
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers: { 'content-type': 'application/json', ...options.headers } });
|
const headers = { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers };
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, { credentials: 'include', ...options, headers });
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.');
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.');
|
||||||
return payload?.data;
|
return payload?.data;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
||||||
|
|
||||||
|
export async function createAdminSale(input, csrfToken) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/admin/sales`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-csrf-token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale request failed.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminSales() {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/admin/sales`, { credentials: 'include' });
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale history is unavailable.');
|
||||||
|
return payload?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function voidAdminSale(id, reason, csrfToken) {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/admin/sales/${encodeURIComponent(id)}/void`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-csrf-token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Sale correction failed.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
test('signs in and creates a product and physical copy from the admin workspace', async ({ page }) => {
|
const tinyPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||||
|
|
||||||
|
test('signs in and publishes a listing after adding an inventory image', async ({ page }) => {
|
||||||
const email = process.env.INITIAL_ADMIN_EMAIL;
|
const email = process.env.INITIAL_ADMIN_EMAIL;
|
||||||
const password = process.env.INITIAL_ADMIN_PASSWORD;
|
const password = process.env.INITIAL_ADMIN_PASSWORD;
|
||||||
|
|
||||||
@@ -24,8 +26,39 @@ test('signs in and creates a product and physical copy from the admin workspace'
|
|||||||
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
|
await page.getByLabel('Condition', { exact: true }).selectOption({ index: 1 });
|
||||||
await page.getByRole('button', { name: 'Create copy' }).click();
|
await page.getByRole('button', { name: 'Create copy' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Manage images')).toBeVisible();
|
const inventoryLink = page.locator('section[aria-labelledby="inventory-title"]').getByRole('link', { name: 'Manage images' }).last();
|
||||||
await page.getByText('Manage images').last().click();
|
await expect(inventoryLink).toBeVisible();
|
||||||
|
const createdInventoryId = await page.getByLabel('Inventory copy').locator('option').last().getAttribute('value');
|
||||||
|
await inventoryLink.click();
|
||||||
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();
|
||||||
|
await page.getByLabel('Add image').setInputFiles({
|
||||||
|
name: 'front.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: tinyPng,
|
||||||
|
});
|
||||||
|
await expect(page.getByLabel('Alt text')).toHaveValue('front.png');
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create listing draft' })).toBeVisible();
|
||||||
|
await page.getByLabel('Inventory copy', { exact: true }).first().selectOption(createdInventoryId);
|
||||||
|
await page.getByLabel('Asking price (cents)').fill('2499');
|
||||||
|
await page.getByRole('button', { name: 'Create listing' }).click();
|
||||||
|
await expect(page.getByText('Add at least one image before publishing.')).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: 'Publish' }).last().click();
|
||||||
|
await expect(page.getByText('AVAILABLE').last()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Inventory copy', { exact: true }).last().selectOption(createdInventoryId);
|
||||||
|
await page.getByLabel('Sale date').fill('2026-09-09');
|
||||||
|
await page.getByLabel('Sold price (cents)').fill('2499');
|
||||||
|
await page.getByLabel('Cost basis (cents)').fill('1200');
|
||||||
|
await page.getByRole('button', { name: 'Record sale' }).click();
|
||||||
|
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · SOLD`)).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sale history' })).toBeVisible();
|
||||||
|
const latestSale = page.locator('section[aria-labelledby="sales-title"] li').first();
|
||||||
|
await expect(latestSale).toContainText('Profit $12.99');
|
||||||
|
await latestSale.getByLabel('Void reason').fill('Browser test correction');
|
||||||
|
await latestSale.getByRole('button', { name: 'Void sale' }).click();
|
||||||
|
await expect(latestSale).toContainText('VOIDED');
|
||||||
|
await expect(page.locator('section[aria-labelledby="inventory-title"]').getByText(`${createdInventoryId} · ACTIVE`)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
dotenv.config({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) });
|
dotenv.config({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) });
|
||||||
|
|
||||||
import { createDatabase } from '../../api/src/infrastructure/database/client.js';
|
import { createDatabase } from '../../api/src/infrastructure/database/client.js';
|
||||||
import { createLocalImageStorage } from '../../api/src/infrastructure/images/local-image-storage.js';
|
import { createLocalImageStorage, resolveProjectImageStorageRoot } from '../../api/src/infrastructure/images/local-image-storage.js';
|
||||||
import { createImageDerivativeProcessor } from '../../api/src/infrastructure/images/image-derivative-processor.js';
|
import { createImageDerivativeProcessor } from '../../api/src/infrastructure/images/image-derivative-processor.js';
|
||||||
import { createSharpImageRenderer } from '../../api/src/infrastructure/images/sharp-image-renderer.js';
|
import { createSharpImageRenderer } from '../../api/src/infrastructure/images/sharp-image-renderer.js';
|
||||||
import { createImageDerivativeJobRepository } from '../../api/src/modules/inventory/repositories/image-derivative-job-repository.js';
|
import { createImageDerivativeJobRepository } from '../../api/src/modules/inventory/repositories/image-derivative-job-repository.js';
|
||||||
@@ -13,8 +13,9 @@ import { processNextPersistentImageDerivativeJob } from './image-derivative-work
|
|||||||
const pollIntervalMs = Number.parseInt(process.env.WORKER_POLL_INTERVAL_MS ?? '2000', 10);
|
const pollIntervalMs = Number.parseInt(process.env.WORKER_POLL_INTERVAL_MS ?? '2000', 10);
|
||||||
|
|
||||||
async function runWorker() {
|
async function runWorker() {
|
||||||
|
const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url);
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const imageStorage = createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage');
|
const imageStorage = createLocalImageStorage(imageStorageRoot);
|
||||||
const jobRepository = createImageDerivativeJobRepository(database.db);
|
const jobRepository = createImageDerivativeJobRepository(database.db);
|
||||||
const processor = createImageDerivativeProcessor({
|
const processor = createImageDerivativeProcessor({
|
||||||
imageStorage,
|
imageStorage,
|
||||||
|
|||||||
@@ -1,198 +1,2 @@
|
|||||||
CREATE TYPE "public"."inventory_status" AS ENUM('ACTIVE', 'SOLD', 'ARCHIVED');--> statement-breakpoint
|
-- This migration was an accidental duplicate schema snapshot.
|
||||||
CREATE TYPE "public"."listing_status" AS ENUM('DRAFT', 'AVAILABLE', 'PENDING', 'CLOSED', 'ARCHIVED');--> statement-breakpoint
|
-- The schema was already created by 0000_core_inventory.sql.
|
||||||
CREATE TYPE "public"."user_role" AS ENUM('ADMIN');--> statement-breakpoint
|
|
||||||
CREATE TABLE "categories" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"sort" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "condition_profiles" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"category_id" uuid NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"sort" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "conditions" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"condition_profile_id" uuid NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"code" text NOT NULL,
|
|
||||||
"quality_rank" integer NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"sort" integer DEFAULT 0 NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "image_derivative_jobs" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"inventory_image_id" uuid NOT NULL,
|
|
||||||
"source_key" text NOT NULL,
|
|
||||||
"status" text DEFAULT 'PENDING' NOT NULL,
|
|
||||||
"derivatives" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"error" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "inventory_images" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"inventory_item_id" uuid NOT NULL,
|
|
||||||
"storage_key" text NOT NULL,
|
|
||||||
"original_filename" text NOT NULL,
|
|
||||||
"mime_type" text NOT NULL,
|
|
||||||
"file_size" integer NOT NULL,
|
|
||||||
"width" integer NOT NULL,
|
|
||||||
"height" integer NOT NULL,
|
|
||||||
"alt_text" text,
|
|
||||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
|
||||||
"is_primary" boolean DEFAULT false NOT NULL,
|
|
||||||
"processing_status" text DEFAULT 'PENDING' NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "inventory_items" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"product_id" uuid NOT NULL,
|
|
||||||
"condition_id" uuid NOT NULL,
|
|
||||||
"status" "inventory_status" DEFAULT 'ACTIVE' NOT NULL,
|
|
||||||
"notes" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "listings" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"inventory_item_id" uuid NOT NULL,
|
|
||||||
"product_name" text NOT NULL,
|
|
||||||
"category_id" uuid NOT NULL,
|
|
||||||
"condition_id" uuid NOT NULL,
|
|
||||||
"asking_price_minor" integer NOT NULL,
|
|
||||||
"status" "listing_status" DEFAULT 'DRAFT' NOT NULL,
|
|
||||||
"notes" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "mfa_methods" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"type" text NOT NULL,
|
|
||||||
"secret_ciphertext" text NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"is_enabled" boolean DEFAULT false NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"last_used_at" timestamp with time zone,
|
|
||||||
"disabled_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "password_reset_tokens" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"token_hash" text NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone NOT NULL,
|
|
||||||
"used_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "products" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"seller_id" uuid NOT NULL,
|
|
||||||
"category_id" uuid NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"brand" text,
|
|
||||||
"set_name" text,
|
|
||||||
"card_number" text,
|
|
||||||
"event_name" text,
|
|
||||||
"release_year" integer,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "products_release_year_valid" CHECK ("products"."release_year" IS NULL OR "products"."release_year" BETWEEN 1996 AND 9999)
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "security_audit_events" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid,
|
|
||||||
"event" text NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "sellers" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"display_name" text NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "sessions" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"session_token_hash" text NOT NULL,
|
|
||||||
"csrf_token_hash" text NOT NULL,
|
|
||||||
"expires_at" timestamp with time zone NOT NULL,
|
|
||||||
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"revoked_at" timestamp with time zone,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"password_hash" text NOT NULL,
|
|
||||||
"role" "user_role" DEFAULT 'ADMIN' NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "condition_profiles" ADD CONSTRAINT "condition_profiles_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "conditions" ADD CONSTRAINT "conditions_condition_profile_id_condition_profiles_id_fk" FOREIGN KEY ("condition_profile_id") REFERENCES "public"."condition_profiles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "image_derivative_jobs" ADD CONSTRAINT "image_derivative_jobs_inventory_image_id_inventory_images_id_fk" FOREIGN KEY ("inventory_image_id") REFERENCES "public"."inventory_images"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "inventory_images" ADD CONSTRAINT "inventory_images_inventory_item_id_inventory_items_id_fk" FOREIGN KEY ("inventory_item_id") REFERENCES "public"."inventory_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_condition_id_conditions_id_fk" FOREIGN KEY ("condition_id") REFERENCES "public"."conditions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "listings" ADD CONSTRAINT "listings_inventory_item_id_inventory_items_id_fk" FOREIGN KEY ("inventory_item_id") REFERENCES "public"."inventory_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "listings" ADD CONSTRAINT "listings_condition_id_conditions_id_fk" FOREIGN KEY ("condition_id") REFERENCES "public"."conditions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "mfa_methods" ADD CONSTRAINT "mfa_methods_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "products" ADD CONSTRAINT "products_seller_id_sellers_id_fk" FOREIGN KEY ("seller_id") REFERENCES "public"."sellers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "products" ADD CONSTRAINT "products_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "security_audit_events" ADD CONSTRAINT "security_audit_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "sellers" ADD CONSTRAINT "sellers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "categories_name_unique" ON "categories" USING btree ("name");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "categories_slug_unique" ON "categories" USING btree ("slug");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "conditions_profile_code_unique" ON "conditions" USING btree ("condition_profile_id","code");--> statement-breakpoint
|
|
||||||
CREATE INDEX "image_derivative_jobs_image_id_index" ON "image_derivative_jobs" USING btree ("inventory_image_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "inventory_images_inventory_item_id_index" ON "inventory_images" USING btree ("inventory_item_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "listings_inventory_item_id_index" ON "listings" USING btree ("inventory_item_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "listings_status_index" ON "listings" USING btree ("status");--> statement-breakpoint
|
|
||||||
CREATE INDEX "mfa_methods_user_id_index" ON "mfa_methods" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "password_reset_tokens_token_hash_unique" ON "password_reset_tokens" USING btree ("token_hash");--> statement-breakpoint
|
|
||||||
CREATE INDEX "password_reset_tokens_user_id_index" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "products_seller_slug_unique" ON "products" USING btree ("seller_id","slug");--> statement-breakpoint
|
|
||||||
CREATE INDEX "security_audit_events_user_id_index" ON "security_audit_events" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "security_audit_events_event_created_at_index" ON "security_audit_events" USING btree ("event","created_at");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "sellers_user_id_unique" ON "sellers" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "sessions_token_hash_unique" ON "sessions" USING btree ("session_token_hash");--> statement-breakpoint
|
|
||||||
CREATE INDEX "sessions_user_id_index" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "sessions_expires_at_index" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE "sales" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"seller_id" uuid NOT NULL REFERENCES "sellers"("id"),
|
||||||
|
"sale_date" text NOT NULL,
|
||||||
|
"sale_location" text,
|
||||||
|
"sold_price_minor" integer NOT NULL,
|
||||||
|
"currency" text NOT NULL,
|
||||||
|
"platform_fees_minor" integer DEFAULT 0 NOT NULL,
|
||||||
|
"payment_processing_fees_minor" integer DEFAULT 0 NOT NULL,
|
||||||
|
"shipping_cost_minor" integer DEFAULT 0 NOT NULL,
|
||||||
|
"taxes_minor" integer DEFAULT 0 NOT NULL,
|
||||||
|
"other_fees_minor" integer DEFAULT 0 NOT NULL,
|
||||||
|
"buyer_name" text,
|
||||||
|
"buyer_contact" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sale_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"sale_id" uuid NOT NULL REFERENCES "sales"("id"),
|
||||||
|
"inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"),
|
||||||
|
"sold_price_minor" integer NOT NULL,
|
||||||
|
"cost_basis_minor" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "sale_items_inventory_item_id_unique" ON "sale_items" USING btree ("inventory_item_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "sale_items_sale_id_index" ON "sale_items" USING btree ("sale_id");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "sales" ADD COLUMN "status" text DEFAULT 'COMPLETED' NOT NULL;
|
||||||
|
ALTER TABLE "sales" ADD COLUMN "void_reason" text;
|
||||||
|
ALTER TABLE "sales" ADD COLUMN "voided_at" timestamp with time zone;
|
||||||
@@ -85,6 +85,20 @@
|
|||||||
"when": 1789545600000,
|
"when": 1789545600000,
|
||||||
"tag": "0011_ensure_image_derivative_jobs",
|
"tag": "0011_ensure_image_derivative_jobs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789632000000,
|
||||||
|
"tag": "0012_sales",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789718400000,
|
||||||
|
"tag": "0013_void_sales",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"./inventory": "./inventory.js",
|
"./inventory": "./inventory.js",
|
||||||
"./image": "./image.js",
|
"./image": "./image.js",
|
||||||
"./listing": "./listing.js",
|
"./listing": "./listing.js",
|
||||||
|
"./sale": "./sale.js",
|
||||||
"./product": "./product.js",
|
"./product": "./product.js",
|
||||||
"./public-listing": "./public-listing.js",
|
"./public-listing": "./public-listing.js",
|
||||||
"./public-inventory": "./public-inventory.js",
|
"./public-inventory": "./public-inventory.js",
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
function toIsoString(value) {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAdminSaleDto({ sale, saleItem, inventoryItem, saleItems = [saleItem], inventoryItems = [inventoryItem], netProceedsMinor, profitMinor, productName }) {
|
||||||
|
return {
|
||||||
|
id: sale.id,
|
||||||
|
sellerId: sale.sellerId,
|
||||||
|
status: sale.status,
|
||||||
|
saleDate: sale.saleDate,
|
||||||
|
saleLocation: sale.saleLocation ?? null,
|
||||||
|
soldPriceMinor: sale.soldPriceMinor,
|
||||||
|
currency: sale.currency,
|
||||||
|
platformFeesMinor: sale.platformFeesMinor,
|
||||||
|
paymentProcessingFeesMinor: sale.paymentProcessingFeesMinor,
|
||||||
|
shippingCostMinor: sale.shippingCostMinor,
|
||||||
|
taxesMinor: sale.taxesMinor,
|
||||||
|
otherFeesMinor: sale.otherFeesMinor,
|
||||||
|
buyerName: sale.buyerName ?? null,
|
||||||
|
buyerContact: sale.buyerContact ?? null,
|
||||||
|
notes: sale.notes ?? null,
|
||||||
|
voidReason: sale.voidReason ?? null,
|
||||||
|
voidedAt: toIsoString(sale.voidedAt) ?? null,
|
||||||
|
productName: productName ?? null,
|
||||||
|
netProceedsMinor: netProceedsMinor ?? null,
|
||||||
|
profitMinor: profitMinor ?? null,
|
||||||
|
saleItem: {
|
||||||
|
id: saleItem.id,
|
||||||
|
inventoryItemId: saleItem.inventoryItemId,
|
||||||
|
soldPriceMinor: saleItem.soldPriceMinor,
|
||||||
|
costBasisMinor: saleItem.costBasisMinor,
|
||||||
|
},
|
||||||
|
saleItems: saleItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
inventoryItemId: item.inventoryItemId,
|
||||||
|
soldPriceMinor: item.soldPriceMinor,
|
||||||
|
costBasisMinor: item.costBasisMinor,
|
||||||
|
})),
|
||||||
|
inventoryItem: {
|
||||||
|
id: inventoryItem.id,
|
||||||
|
status: inventoryItem.status,
|
||||||
|
},
|
||||||
|
inventoryItems: inventoryItems.map((item) => ({ id: item.id, status: item.status })),
|
||||||
|
createdAt: toIsoString(sale.createdAt),
|
||||||
|
updatedAt: toIsoString(sale.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"./inventory": "./inventory.js",
|
"./inventory": "./inventory.js",
|
||||||
"./image": "./image.js",
|
"./image": "./image.js",
|
||||||
"./listing": "./listing.js",
|
"./listing": "./listing.js",
|
||||||
|
"./sale": "./sale.js",
|
||||||
"./product": "./product.js"
|
"./product": "./product.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const currencyCode = /^[A-Za-z]{3}$/;
|
||||||
|
const saleItemSchema = z.object({
|
||||||
|
inventoryItemId: z.uuid(),
|
||||||
|
soldPriceMinor: z.int().positive(),
|
||||||
|
costBasisMinor: z.int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createSaleSchema = z.object({
|
||||||
|
inventoryItemId: z.uuid(),
|
||||||
|
costBasisMinor: z.int().nonnegative(),
|
||||||
|
saleDate: z.string().trim().regex(isoDate),
|
||||||
|
saleLocation: z.string().trim().max(255).optional(),
|
||||||
|
soldPriceMinor: z.int().positive(),
|
||||||
|
currency: z.string().trim().regex(currencyCode).optional(),
|
||||||
|
platformFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
shippingCostMinor: z.int().nonnegative().optional(),
|
||||||
|
taxesMinor: z.int().nonnegative().optional(),
|
||||||
|
otherFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
buyerName: z.string().trim().max(255).optional(),
|
||||||
|
buyerContact: z.string().trim().max(255).optional(),
|
||||||
|
notes: z.string().trim().max(10000).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const createMultiItemSaleSchema = z.object({
|
||||||
|
items: z.array(saleItemSchema).min(2),
|
||||||
|
saleDate: z.string().trim().regex(isoDate),
|
||||||
|
saleLocation: z.string().trim().max(255).optional(),
|
||||||
|
currency: z.string().trim().regex(currencyCode).optional(),
|
||||||
|
platformFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
paymentProcessingFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
shippingCostMinor: z.int().nonnegative().optional(),
|
||||||
|
taxesMinor: z.int().nonnegative().optional(),
|
||||||
|
otherFeesMinor: z.int().nonnegative().optional(),
|
||||||
|
buyerName: z.string().trim().max(255).optional(),
|
||||||
|
buyerContact: z.string().trim().max(255).optional(),
|
||||||
|
notes: z.string().trim().max(10000).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const voidSaleSchema = z.object({
|
||||||
|
reason: z.string().trim().min(3).max(1000),
|
||||||
|
}).strict();
|
||||||
Reference in New Issue
Block a user