diff --git a/.env.example b/.env.example index d874bfc..6ec3be6 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ INITIAL_ADMIN_PASSWORD= INITIAL_SELLER_NAME= MFA_ENCRYPTION_KEY= PASSWORD_RESET_URL=http://localhost:5173/reset-password -SMTP_FROM= -SMTP_URL= +SMTP_FROM=no-reply@example.test +SMTP_URL=smtp://localhost:1025 POSTGRES_DB=pokemon_inventory POSTGRES_PASSWORD=change-me POSTGRES_PORT=5433 diff --git a/README.md b/README.md index 0fbc0a2..4f53482 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,15 @@ Integration tests reject database names not ending in `_test` and truncate their ## 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= +``` + +The MFA key must remain stable while enrolled MFA methods exist. Keep it in local secret storage and do not commit it. ```sh npm run dev @@ -132,8 +140,8 @@ After the development database is migrated, set `INITIAL_ADMIN_EMAIL`, `INITIAL_ ## 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 -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. diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 11fb7ee..65c8334 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -14,6 +14,7 @@ import { registerAdminProductRoutes } from '../modules/products/routes/admin-pro import { registerPublicProductRoutes } from '../modules/products/routes/public-product-routes.js'; import { registerPublicSearchRoutes } from '../modules/search/routes/public-search-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) { const app = Fastify(); @@ -61,6 +62,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerAdminSaleRoutes, { + ...dependencies, + prefix: '/api/v1/admin', + }); app.register(registerPublicSearchRoutes, { ...dependencies, prefix: '/api/v1', diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js index 19b1dd3..a27e9ea 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -5,6 +5,7 @@ dotenv.config({ path: fileURLToPath(new URL('../../../../.env', import.meta.url) import { buildApp } from './build-app.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 { createRedisConnection } from '../infrastructure/redis/client.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 { createSellerRepository } from '../modules/sellers/repositories/seller-repository.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) { const port = Number.parseInt(value ?? '3000', 10); @@ -48,6 +51,7 @@ function parsePort(value) { } async function startServer() { + const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url); const database = createDatabase(); const redis = await createRedisConnection(); const productRepository = createProductRepository(database.db); @@ -58,6 +62,7 @@ async function startServer() { const listingRepository = createListingRepository(database.db); const sellerRepository = createSellerRepository(database.db); const conditionRepository = createConditionRepository(database.db); + const saleRepository = createSaleRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -71,7 +76,7 @@ async function startServer() { }); const passwordResetNotifier = createPasswordResetNotifier(); const publicImageService = createPublicImageService({ - imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), + imageStorage: createLocalImageStorage(imageStorageRoot), inventoryImageRepository, }); const authenticationService = createAuthenticationService({ @@ -108,11 +113,12 @@ async function startServer() { publicImageService, publicInventoryService: createPublicInventoryService({ inventoryItemRepository, publicImageService }), inventoryImageService: createInventoryImageService({ - imageStorage: createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'), + imageStorage: createLocalImageStorage(imageStorageRoot), imageDerivativeJobRepository, inventoryImageRepository, }), listingService: createListingService({ listingRepository, inventoryImageRepository }), + saleService: createSaleService({ saleRepository }), publicListingService: createPublicListingService({ listingRepository, publicImageService }), productService: createProductService({ productRepository }), publicProductService: createPublicProductService({ productRepository }), diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js index d812ab4..bb9b232 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -297,4 +297,46 @@ export const listings = pgTable('listings', { }, (table) => [ index('listings_inventory_item_id_index').on(table.inventoryItemId), index('listings_status_index').on(table.status), -]); \ No newline at end of file +]); + +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), + ], +); \ No newline at end of file diff --git a/apps/api/src/infrastructure/images/local-image-storage.js b/apps/api/src/infrastructure/images/local-image-storage.js index d533708..9a4f712 100644 --- a/apps/api/src/infrastructure/images/local-image-storage.js +++ b/apps/api/src/infrastructure/images/local-image-storage.js @@ -1,8 +1,37 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; 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) { if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) { throw new Error('Image storage key must be a relative non-empty path.'); diff --git a/apps/api/src/modules/sales/domain/sale.js b/apps/api/src/modules/sales/domain/sale.js index 766dee6..1fc50d6 100644 --- a/apps/api/src/modules/sales/domain/sale.js +++ b/apps/api/src/modules/sales/domain/sale.js @@ -22,6 +22,37 @@ function requireNonNegativeMinorUnit(value, fieldName) { 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({ sellerId, saleDate, @@ -39,12 +70,12 @@ export function createSale({ }) { return { sellerId: requiredText(sellerId, 'Seller ID'), - saleDate: requiredText(saleDate, 'Sale date'), + saleDate: requireIsoDate(saleDate, 'Sale date'), ...(typeof saleLocation === 'string' && saleLocation.trim().length > 0 ? { saleLocation: saleLocation.trim() } : {}), soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), - currency: requiredText(currency, 'Currency'), + currency: requireCurrency(currency), platformFeesMinor: requireNonNegativeMinorUnit(platformFeesMinor, 'Platform fees'), paymentProcessingFeesMinor: requireNonNegativeMinorUnit( paymentProcessingFeesMinor, @@ -63,17 +94,18 @@ export function createSale({ }; } -export function createSaleItem({ - saleId, - inventoryItemId, - soldPriceMinor, - costBasisMinor, -}) { +export function validateSaleItem({ soldPriceMinor, costBasisMinor }) { + return { + soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), + costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'), + }; +} + +export function createSaleItem({ saleId, inventoryItemId, soldPriceMinor, costBasisMinor }) { return { saleId: requiredText(saleId, 'Sale ID'), inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'), - soldPriceMinor: requirePositiveMinorUnit(soldPriceMinor, 'Sold price'), - costBasisMinor: requireNonNegativeMinorUnit(costBasisMinor, 'Cost basis'), + ...validateSaleItem({ soldPriceMinor, costBasisMinor }), }; } diff --git a/apps/api/src/modules/sales/repositories/sale-repository.js b/apps/api/src/modules/sales/repositories/sale-repository.js new file mode 100644 index 0000000..f307665 --- /dev/null +++ b/apps/api/src/modules/sales/repositories/sale-repository.js @@ -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, + }; + }); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/sales/routes/admin-sale-routes.js b/apps/api/src/modules/sales/routes/admin-sale-routes.js new file mode 100644 index 0000000..75450d0 --- /dev/null +++ b/apps/api/src/modules/sales/routes/admin-sale-routes.js @@ -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) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/sales/services/sale-service.js b/apps/api/src/modules/sales/services/sale-service.js new file mode 100644 index 0000000..f6029c8 --- /dev/null +++ b/apps/api/src/modules/sales/services/sale-service.js @@ -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), + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/image-storage.test.js b/apps/api/tests/application/image-storage.test.js index 1a739a0..a683bf7 100644 --- a/apps/api/tests/application/image-storage.test.js +++ b/apps/api/tests/application/image-storage.test.js @@ -2,10 +2,11 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import test from 'node:test'; 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 () => { 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', () => { assert.throws( () => createImageStorageContract({ put() {} }), diff --git a/apps/api/tests/application/sale-service.test.js b/apps/api/tests/application/sale-service.test.js new file mode 100644 index 0000000..7388b69 --- /dev/null +++ b/apps/api/tests/application/sale-service.test.js @@ -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, + ); + } +}); \ No newline at end of file diff --git a/apps/api/tests/domain/sale.test.js b/apps/api/tests/domain/sale.test.js index 75d492d..35e4ed5 100644 --- a/apps/api/tests/domain/sale.test.js +++ b/apps/api/tests/domain/sale.test.js @@ -54,4 +54,23 @@ test('rejects invalid sales and negative fee values', () => { }), /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/, + ); }); diff --git a/apps/api/tests/http/sale-routes.test.js b/apps/api/tests/http/sale-routes.test.js new file mode 100644 index 0000000..06c7ff5 --- /dev/null +++ b/apps/api/tests/http/sale-routes.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/web/src/components/organisms/AdminImageManager.stories.js b/apps/web/src/components/organisms/AdminImageManager.stories.js index 2e40ca1..0a48db8 100644 --- a/apps/web/src/components/organisms/AdminImageManager.stories.js +++ b/apps/web/src/components/organisms/AdminImageManager.stories.js @@ -10,6 +10,7 @@ const images = [ export default { title: 'Organisms/AdminImageManager', component: AdminImageManager }; export const Completed = { args: { images, processingStatus: 'COMPLETED' } }; +export const EmptyUpload = { args: { images: [], processingStatus: 'COMPLETED' } }; export const Processing = { args: { images, processingStatus: 'PROCESSING' } }; export const Failed = { args: { images, processingStatus: 'FAILED', errorMessage: 'Derivative generation failed.' } }; export const Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } }; diff --git a/apps/web/src/components/organisms/AdminImageManager.vue b/apps/web/src/components/organisms/AdminImageManager.vue index 77ec677..a9386b9 100644 --- a/apps/web/src/components/organisms/AdminImageManager.vue +++ b/apps/web/src/components/organisms/AdminImageManager.vue @@ -11,7 +11,7 @@ const props = defineProps({ errorMessage: { type: String, default: '' }, 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 isEditingAltText = ref(false); const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder)); @@ -20,6 +20,12 @@ function saveAltText() { emit('alt-text', altText.value); isEditingAltText.value = false; } + +function handleFileChange(event) { + const [file] = event.target.files; + if (file) emit('upload', file); + event.target.value = ''; +} diff --git a/apps/web/src/pages/AdminImagePage.vue b/apps/web/src/pages/AdminImagePage.vue index feecbc7..a680f86 100644 --- a/apps/web/src/pages/AdminImagePage.vue +++ b/apps/web/src/pages/AdminImagePage.vue @@ -7,7 +7,7 @@ import { useAdminImages } from '@/composables/useAdminImages.js'; const route = useRoute(); 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); @@ -23,6 +23,7 @@ onMounted(load); v-else :images="images" :processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')" + @upload="upload" @reorder="reorder($event.id, $event.sortOrder)" @primary="setPrimary" @alt-text="updateAltText(images[0]?.id, $event)" diff --git a/apps/web/src/services/admin-image-service.js b/apps/web/src/services/admin-image-service.js index 7e80593..b96d7e1 100644 --- a/apps/web/src/services/admin-image-service.js +++ b/apps/web/src/services/admin-image-service.js @@ -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) { return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, { method: 'GET', diff --git a/apps/web/src/services/admin-listing-service.js b/apps/web/src/services/admin-listing-service.js index 5855b08..27eaea6 100644 --- a/apps/web/src/services/admin-listing-service.js +++ b/apps/web/src/services/admin-listing-service.js @@ -1,7 +1,8 @@ const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; 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); if (!response.ok) throw new Error(payload?.error?.message ?? 'Listing request failed.'); return payload?.data; diff --git a/apps/web/src/services/admin-sale-service.js b/apps/web/src/services/admin-sale-service.js new file mode 100644 index 0000000..a32a858 --- /dev/null +++ b/apps/web/src/services/admin-sale-service.js @@ -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; +} diff --git a/apps/web/tests/e2e/admin-catalog-workflow.spec.js b/apps/web/tests/e2e/admin-catalog-workflow.spec.js index 1c9f52a..32b9694 100644 --- a/apps/web/tests/e2e/admin-catalog-workflow.spec.js +++ b/apps/web/tests/e2e/admin-catalog-workflow.spec.js @@ -1,6 +1,8 @@ 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 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.getByRole('button', { name: 'Create copy' }).click(); - await expect(page.getByText('Manage images')).toBeVisible(); - await page.getByText('Manage images').last().click(); + const inventoryLink = page.locator('section[aria-labelledby="inventory-title"]').getByRole('link', { name: 'Manage images' }).last(); + 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.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(); }); diff --git a/apps/worker/src/worker-runtime.js b/apps/worker/src/worker-runtime.js index 5249caf..bc853a4 100644 --- a/apps/worker/src/worker-runtime.js +++ b/apps/worker/src/worker-runtime.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; dotenv.config({ path: fileURLToPath(new URL('../../../.env', import.meta.url)) }); 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 { createSharpImageRenderer } from '../../api/src/infrastructure/images/sharp-image-renderer.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); async function runWorker() { + const imageStorageRoot = resolveProjectImageStorageRoot(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage', import.meta.url); const database = createDatabase(); - const imageStorage = createLocalImageStorage(process.env.IMAGE_STORAGE_ROOT ?? '.local/image-storage'); + const imageStorage = createLocalImageStorage(imageStorageRoot); const jobRepository = createImageDerivativeJobRepository(database.db); const processor = createImageDerivativeProcessor({ imageStorage, diff --git a/database/migrations/0010_plain_charles_xavier.sql b/database/migrations/0010_plain_charles_xavier.sql index 8fd2c8c..467e9c2 100644 --- a/database/migrations/0010_plain_charles_xavier.sql +++ b/database/migrations/0010_plain_charles_xavier.sql @@ -1,198 +1,2 @@ -CREATE TYPE "public"."inventory_status" AS ENUM('ACTIVE', 'SOLD', 'ARCHIVED');--> statement-breakpoint -CREATE TYPE "public"."listing_status" AS ENUM('DRAFT', 'AVAILABLE', 'PENDING', 'CLOSED', 'ARCHIVED');--> statement-breakpoint -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"); \ No newline at end of file +-- This migration was an accidental duplicate schema snapshot. +-- The schema was already created by 0000_core_inventory.sql. diff --git a/database/migrations/0012_sales.sql b/database/migrations/0012_sales.sql new file mode 100644 index 0000000..3b2b5d5 --- /dev/null +++ b/database/migrations/0012_sales.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/0013_void_sales.sql b/database/migrations/0013_void_sales.sql new file mode 100644 index 0000000..bee2d95 --- /dev/null +++ b/database/migrations/0013_void_sales.sql @@ -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; \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 8f8a3df..77f4f84 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -85,6 +85,20 @@ "when": 1789545600000, "tag": "0011_ensure_image_derivative_jobs", "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 } ] } \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c6f6243..7659dc2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -7,6 +7,7 @@ "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js", + "./sale": "./sale.js", "./product": "./product.js", "./public-listing": "./public-listing.js", "./public-inventory": "./public-inventory.js", diff --git a/packages/contracts/sale.js b/packages/contracts/sale.js new file mode 100644 index 0000000..9b5ab66 --- /dev/null +++ b/packages/contracts/sale.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), + }; +} \ No newline at end of file diff --git a/packages/validation/package.json b/packages/validation/package.json index d476922..7e1595f 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -7,6 +7,7 @@ "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js", + "./sale": "./sale.js", "./product": "./product.js" }, "dependencies": { diff --git a/packages/validation/sale.js b/packages/validation/sale.js new file mode 100644 index 0000000..57b7d1d --- /dev/null +++ b/packages/validation/sale.js @@ -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(); \ No newline at end of file