From eee8e1978f926ae88c47a526e7c5adce763c8f6b Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 00:39:56 -0400 Subject: [PATCH] feat: add acquisition API foundation Add acquisition persistence, cost-basis calculation, seller-scoped admin routes, contracts, validation, migration, and focused application/HTTP tests. --- apps/api/src/app/build-app.js | 5 ++ apps/api/src/app/server.js | 4 ++ .../api/src/infrastructure/database/schema.js | 19 +++++++ .../repositories/acquisition-repository.js | 32 +++++++++++ .../routes/admin-acquisition-routes.js | 42 ++++++++++++++ .../services/acquisition-service.js | 23 ++++++++ .../application/acquisition-service.test.js | 41 +++++++++++++ .../api/tests/http/acquisition-routes.test.js | 57 +++++++++++++++++++ database/migrations/0014_acquisitions.sql | 19 +++++++ database/migrations/meta/_journal.json | 7 +++ packages/contracts/acquisition.js | 24 ++++++++ packages/contracts/package.json | 1 + packages/validation/acquisition.js | 16 ++++++ packages/validation/package.json | 1 + 14 files changed, 291 insertions(+) create mode 100644 apps/api/src/modules/acquisitions/repositories/acquisition-repository.js create mode 100644 apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js create mode 100644 apps/api/src/modules/acquisitions/services/acquisition-service.js create mode 100644 apps/api/tests/application/acquisition-service.test.js create mode 100644 apps/api/tests/http/acquisition-routes.test.js create mode 100644 database/migrations/0014_acquisitions.sql create mode 100644 packages/contracts/acquisition.js create mode 100644 packages/validation/acquisition.js diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js index 65c8334..9a98740 100644 --- a/apps/api/src/app/build-app.js +++ b/apps/api/src/app/build-app.js @@ -15,6 +15,7 @@ import { registerPublicProductRoutes } from '../modules/products/routes/public-p 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'; +import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js'; export function buildApp(dependencies) { const app = Fastify(); @@ -66,6 +67,10 @@ export function buildApp(dependencies) { ...dependencies, prefix: '/api/v1/admin', }); + app.register(registerAdminAcquisitionRoutes, { + ...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 a27e9ea..b5a6202 100644 --- a/apps/api/src/app/server.js +++ b/apps/api/src/app/server.js @@ -39,6 +39,8 @@ import { createSellerRepository } from '../modules/sellers/repositories/seller-r 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'; +import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js'; +import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js'; function parsePort(value) { const port = Number.parseInt(value ?? '3000', 10); @@ -63,6 +65,7 @@ async function startServer() { const sellerRepository = createSellerRepository(database.db); const conditionRepository = createConditionRepository(database.db); const saleRepository = createSaleRepository(database.db); + const acquisitionRepository = createAcquisitionRepository(database.db); const userRepository = createUserRepository(database.db); const sessionRepository = createSessionRepository(database.db); const passwordResetRepository = createPasswordResetRepository(database.db); @@ -119,6 +122,7 @@ async function startServer() { }), listingService: createListingService({ listingRepository, inventoryImageRepository }), saleService: createSaleService({ saleRepository }), + acquisitionService: createAcquisitionService({ acquisitionRepository }), 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 bb9b232..8dfa5b1 100644 --- a/apps/api/src/infrastructure/database/schema.js +++ b/apps/api/src/infrastructure/database/schema.js @@ -242,6 +242,25 @@ export const inventoryItems = pgTable('inventory_items', { ...timestamps, }); +export const acquisitions = pgTable('acquisitions', { + id: uuid('id').defaultRandom().primaryKey(), + inventoryItemId: uuid('inventory_item_id') + .notNull() + .references(() => inventoryItems.id), + method: text('method').notNull(), + costMinor: integer('cost_minor').notNull(), + currency: text('currency').notNull(), + date: text('date'), + source: text('source'), + location: text('location'), + shippingMinor: integer('shipping_minor').default(0).notNull(), + taxMinor: integer('tax_minor').default(0).notNull(), + otherFeesMinor: integer('other_fees_minor').default(0).notNull(), + estimatedTradeValueMinor: integer('estimated_trade_value_minor').default(0).notNull(), + notes: text('notes'), + ...timestamps, +}); + export const inventoryImages = pgTable( 'inventory_images', { diff --git a/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js b/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js new file mode 100644 index 0000000..f5cccb1 --- /dev/null +++ b/apps/api/src/modules/acquisitions/repositories/acquisition-repository.js @@ -0,0 +1,32 @@ +import { and, desc, eq } from 'drizzle-orm'; + +import { acquisitions, inventoryItems, products } from '../../../infrastructure/database/schema.js'; + +export function createAcquisitionRepository(db) { + return { + async createForSeller({ acquisition, sellerId }) { + return db.transaction(async (transaction) => { + const [ownedItem] = await transaction + .select({ id: inventoryItems.id }) + .from(inventoryItems) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and(eq(inventoryItems.id, acquisition.inventoryItemId), eq(products.sellerId, sellerId))); + + if (!ownedItem) return { status: 'NOT_FOUND' }; + const [created] = await transaction.insert(acquisitions).values(acquisition).returning(); + return { status: 'CREATED', acquisition: created }; + }); + }, + + async listBySellerId(sellerId) { + return db + .select({ acquisition: acquisitions }) + .from(acquisitions) + .innerJoin(inventoryItems, eq(inventoryItems.id, acquisitions.inventoryItemId)) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(eq(products.sellerId, sellerId)) + .orderBy(desc(acquisitions.createdAt)) + .then((rows) => rows.map(({ acquisition }) => acquisition)); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js b/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js new file mode 100644 index 0000000..9bdca8d --- /dev/null +++ b/apps/api/src/modules/acquisitions/routes/admin-acquisition-routes.js @@ -0,0 +1,42 @@ +import { toAdminAcquisitionDto } from '@pokemon-inventory/contracts/acquisition'; +import { createAcquisitionSchema } from '@pokemon-inventory/validation/acquisition'; + +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 })), + }, + }); +} + +export async function registerAdminAcquisitionRoutes(app, options) { + const { + authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); }, + authorizeUnsafeRequest, + sessionCookieName = 'pokemon_inventory_session', + acquisitionService, + } = options; + + app.addHook('preHandler', async (request) => { + const admin = await authenticateAdmin(request); + if (!admin?.sellerId) throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); + request.admin = admin; + }); + + app.get('/acquisitions', async (request) => ({ + data: (await acquisitionService.list({ sellerId: request.admin.sellerId })).map(toAdminAcquisitionDto), + })); + + app.post('/acquisitions', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); + const result = createAcquisitionSchema.safeParse(request.body); + if (!result.success) return validationError(reply, result); + + const acquisition = await acquisitionService.create({ ...result.data, sellerId: request.admin.sellerId }); + return reply.code(201).send({ data: toAdminAcquisitionDto(acquisition) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/acquisitions/services/acquisition-service.js b/apps/api/src/modules/acquisitions/services/acquisition-service.js new file mode 100644 index 0000000..c22da66 --- /dev/null +++ b/apps/api/src/modules/acquisitions/services/acquisition-service.js @@ -0,0 +1,23 @@ +import { createAcquisition, calculateAcquisitionCostBasis } from '../domain/acquisition.js'; +import { createApplicationError } from '../../../shared/errors/application-error.js'; + +export function createAcquisitionService({ acquisitionRepository }) { + return { + async create({ sellerId, ...input }) { + const acquisition = createAcquisition(input); + const result = await acquisitionRepository.createForSeller({ acquisition, sellerId }); + if (result.status === 'NOT_FOUND') { + throw createApplicationError('INVENTORY_NOT_FOUND', 'Inventory item was not found.', 404); + } + + return { + ...result.acquisition, + costBasisMinor: calculateAcquisitionCostBasis(acquisition), + }; + }, + + async list({ sellerId }) { + return acquisitionRepository.listBySellerId(sellerId); + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/acquisition-service.test.js b/apps/api/tests/application/acquisition-service.test.js new file mode 100644 index 0000000..18db129 --- /dev/null +++ b/apps/api/tests/application/acquisition-service.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createAcquisitionService } from '../../src/modules/acquisitions/services/acquisition-service.js'; + +test('creates an owned acquisition and calculates cost basis', async () => { + let received; + const service = createAcquisitionService({ + acquisitionRepository: { + async createForSeller(input) { + received = input; + return { status: 'CREATED', acquisition: { id: 'acquisition-1', ...input.acquisition } }; + }, + }, + }); + + const result = await service.create({ + sellerId: 'seller-1', + inventoryItemId: 'inventory-1', + method: 'PURCHASE', + costMinor: 1000, + shippingMinor: 150, + taxMinor: 75, + otherFeesMinor: 50, + estimatedTradeValueMinor: 200, + }); + + assert.equal(received.sellerId, 'seller-1'); + assert.equal(result.costBasisMinor, 1075); +}); + +test('rejects acquisition for an inventory item outside the seller account', async () => { + const service = createAcquisitionService({ + acquisitionRepository: { async createForSeller() { return { status: 'NOT_FOUND' }; } }, + }); + + await assert.rejects( + () => service.create({ sellerId: 'seller-1', inventoryItemId: 'inventory-1', method: 'PURCHASE', costMinor: 100 }), + (error) => error.code === 'INVENTORY_NOT_FOUND' && error.statusCode === 404, + ); +}); \ No newline at end of file diff --git a/apps/api/tests/http/acquisition-routes.test.js b/apps/api/tests/http/acquisition-routes.test.js new file mode 100644 index 0000000..7b9ba2c --- /dev/null +++ b/apps/api/tests/http/acquisition-routes.test.js @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildApp } from '../../src/app/build-app.js'; + +function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) { + return buildApp({ + authenticateAdmin, + authorizeUnsafeRequest, + acquisitionService: { + async create(input) { + return { id: 'acquisition-1', ...input, costBasisMinor: 1075 }; + }, + async list() { + return []; + }, + }, + }); +} + +test('creates an acquisition for the authenticated seller', async (testContext) => { + let csrfChecked = false; + const app = createTestApp({ + authenticateAdmin: async () => ({ sellerId: 'seller-1' }), + authorizeUnsafeRequest: async () => { csrfChecked = true; }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/acquisitions', + headers: { 'x-csrf-token': 'csrf-token' }, + payload: { + inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8', + method: 'PURCHASE', + costMinor: 1000, + }, + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().data.costBasisMinor, 1075); + assert.equal(csrfChecked, true); +}); + +test('rejects invalid acquisition requests', async (testContext) => { + const app = createTestApp({ authenticateAdmin: async () => ({ sellerId: 'seller-1' }) }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'POST', + url: '/api/v1/admin/acquisitions', + payload: { inventoryItemId: 'not-a-uuid', method: '', costMinor: -1 }, + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().error.code, 'VALIDATION_ERROR'); +}); \ No newline at end of file diff --git a/database/migrations/0014_acquisitions.sql b/database/migrations/0014_acquisitions.sql new file mode 100644 index 0000000..714d961 --- /dev/null +++ b/database/migrations/0014_acquisitions.sql @@ -0,0 +1,19 @@ +CREATE TABLE "acquisitions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"), + "method" text NOT NULL, + "cost_minor" integer NOT NULL, + "currency" text NOT NULL, + "date" text, + "source" text, + "location" text, + "shipping_minor" integer DEFAULT 0 NOT NULL, + "tax_minor" integer DEFAULT 0 NOT NULL, + "other_fees_minor" integer DEFAULT 0 NOT NULL, + "estimated_trade_value_minor" integer DEFAULT 0 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 INDEX "acquisitions_inventory_item_id_index" ON "acquisitions" USING btree ("inventory_item_id"); \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json index 77f4f84..9898ee4 100644 --- a/database/migrations/meta/_journal.json +++ b/database/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1789718400000, "tag": "0013_void_sales", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1789804800000, + "tag": "0014_acquisitions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/contracts/acquisition.js b/packages/contracts/acquisition.js new file mode 100644 index 0000000..a4dd867 --- /dev/null +++ b/packages/contracts/acquisition.js @@ -0,0 +1,24 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toAdminAcquisitionDto(acquisition) { + return { + id: acquisition.id, + inventoryItemId: acquisition.inventoryItemId, + method: acquisition.method, + costMinor: acquisition.costMinor, + currency: acquisition.currency, + date: acquisition.date ?? null, + source: acquisition.source ?? null, + location: acquisition.location ?? null, + shippingMinor: acquisition.shippingMinor, + taxMinor: acquisition.taxMinor, + otherFeesMinor: acquisition.otherFeesMinor, + estimatedTradeValueMinor: acquisition.estimatedTradeValueMinor, + costBasisMinor: acquisition.costBasisMinor ?? null, + notes: acquisition.notes ?? null, + createdAt: toIsoString(acquisition.createdAt), + updatedAt: toIsoString(acquisition.updatedAt), + }; +} \ No newline at end of file diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 7659dc2..9f7696d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { "./auth": "./auth.js", + "./acquisition": "./acquisition.js", "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js", diff --git a/packages/validation/acquisition.js b/packages/validation/acquisition.js new file mode 100644 index 0000000..f490a4f --- /dev/null +++ b/packages/validation/acquisition.js @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const createAcquisitionSchema = z.object({ + inventoryItemId: z.uuid(), + method: z.string().trim().min(1).max(100), + costMinor: z.int().nonnegative(), + currency: z.string().trim().regex(/^[A-Za-z]{3}$/).optional(), + date: z.string().trim().max(10).optional(), + source: z.string().trim().max(255).optional(), + location: z.string().trim().max(255).optional(), + shippingMinor: z.int().nonnegative().optional(), + taxMinor: z.int().nonnegative().optional(), + otherFeesMinor: z.int().nonnegative().optional(), + estimatedTradeValueMinor: z.int().nonnegative().optional(), + notes: z.string().trim().max(10000).optional(), +}).strict(); \ No newline at end of file diff --git a/packages/validation/package.json b/packages/validation/package.json index 7e1595f..8d218a6 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { "./auth": "./auth.js", + "./acquisition": "./acquisition.js", "./inventory": "./inventory.js", "./image": "./image.js", "./listing": "./listing.js",