From ba88814af0d14a81c3af01674a1ef2eb1b1d2a77 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Wed, 9 Sep 2026 01:15:30 -0400 Subject: [PATCH] feat: add admin pricing workflow Add the admin pricing observation and valuation form, secure valuation writes with CSRF, and extend authenticated browser coverage. --- .../pricing/routes/admin-pricing-routes.js | 1 + apps/api/tests/http/pricing-routes.test.js | 2 +- apps/web/src/pages/AdminHomePage.vue | 37 +++++++++++++++++++ .../web/src/services/admin-pricing-service.js | 27 ++++++++++++++ .../tests/e2e/admin-catalog-workflow.spec.js | 5 +++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/services/admin-pricing-service.js diff --git a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js index fb29e76..0d4f889 100644 --- a/apps/api/src/modules/pricing/routes/admin-pricing-routes.js +++ b/apps/api/src/modules/pricing/routes/admin-pricing-routes.js @@ -36,6 +36,7 @@ export async function registerAdminPricingRoutes(app, options) { }); app.post('/inventory/:id/valuation', async (request, reply) => { + await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']); const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id }); return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) }); }); diff --git a/apps/api/tests/http/pricing-routes.test.js b/apps/api/tests/http/pricing-routes.test.js index 4ff6712..9057c3d 100644 --- a/apps/api/tests/http/pricing-routes.test.js +++ b/apps/api/tests/http/pricing-routes.test.js @@ -24,5 +24,5 @@ test('accepts a protected pricing observation and valuation request', async (tes assert.equal(observationResponse.statusCode, 201); assert.equal(valuationResponse.statusCode, 200); assert.equal(valuationResponse.json().data.valueMinor, 2500); - assert.equal(csrfChecks, 1); + assert.equal(csrfChecks, 2); }); \ No newline at end of file diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue index ffbc4a3..e625f47 100644 --- a/apps/web/src/pages/AdminHomePage.vue +++ b/apps/web/src/pages/AdminHomePage.vue @@ -9,6 +9,7 @@ import { createAdminListing, listAdminListings, markListingPending, publishAdmin import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js'; import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js'; import { getAdminDashboard } from '@/services/admin-dashboard-service.js'; +import { addAdminPricingObservation, calculateAdminValuation } from '@/services/admin-pricing-service.js'; const { principal } = useAuth(); const products = ref([]); @@ -17,6 +18,7 @@ const listings = ref([]); const sales = ref([]); const acquisitions = ref([]); const dashboard = ref(null); +const valuations = ref({}); const categories = ref([]); const conditions = ref([]); const isLoading = ref(true); @@ -38,6 +40,10 @@ const acquisitionCost = ref(''); const acquisitionShipping = ref(''); const acquisitionTax = ref(''); const acquisitionDate = ref(new Date().toISOString().slice(0, 10)); +const pricingInventoryId = ref(''); +const pricingProvider = ref('manual'); +const pricingSource = ref(''); +const pricingPrice = ref(''); const voidReasons = ref({}); function csrfToken() { @@ -132,6 +138,19 @@ async function recordAcquisition() { finally { isSaving.value = false; } } +async function calculatePricing() { + isSaving.value = true; + try { + const item = inventory.value.find(({ id }) => id === pricingInventoryId.value); + await addAdminPricingObservation({ productId: item.productId, inventoryItemId: item.id, providerId: pricingProvider.value, priceMinor: Number(pricingPrice.value), sourceIdentifier: pricingSource.value, conditionId: item.conditionId }, csrfToken()); + const valuation = await calculateAdminValuation(item.id, csrfToken()); + valuations.value = { ...valuations.value, [item.id]: valuation }; + pricingSource.value = ''; + pricingPrice.value = ''; + } catch (requestError) { error.value = requestError; } + finally { isSaving.value = false; } +} + async function voidSale(saleId) { isSaving.value = true; try { @@ -241,6 +260,21 @@ onMounted(load); +
+

Calculate market value

+ + + + + + + + + +
@@ -252,6 +286,9 @@ onMounted(load);

Physical inventory

No physical copies yet.

+

Listings

diff --git a/apps/web/src/services/admin-pricing-service.js b/apps/web/src/services/admin-pricing-service.js new file mode 100644 index 0000000..621ebe6 --- /dev/null +++ b/apps/web/src/services/admin-pricing-service.js @@ -0,0 +1,27 @@ +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: { ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error?.message ?? 'Pricing request failed.'); + return payload?.data; +} + +export function addAdminPricingObservation(input, csrfToken) { + return request('/admin/pricing/observations', { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + body: JSON.stringify(input), + }); +} + +export function calculateAdminValuation(inventoryItemId, csrfToken) { + return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/valuation`, { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + }); +} diff --git a/apps/web/tests/e2e/admin-catalog-workflow.spec.js b/apps/web/tests/e2e/admin-catalog-workflow.spec.js index 7f99e3c..6cff0ad 100644 --- a/apps/web/tests/e2e/admin-catalog-workflow.spec.js +++ b/apps/web/tests/e2e/admin-catalog-workflow.spec.js @@ -43,6 +43,11 @@ test('signs in and publishes a listing after adding an inventory image', async ( await page.getByLabel('Tax (cents)').fill('50'); await page.getByRole('button', { name: 'Record acquisition' }).click(); await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50'); + await page.locator('#pricing-inventory').selectOption(createdInventoryId); + await page.getByLabel('Source identifier').fill('browser-price-1'); + await page.getByLabel('Observed price (cents)').fill('2500'); + await page.getByRole('button', { name: 'Add observation and calculate' }).click(); + await expect(page.getByRole('list', { name: 'Market valuations' })).toContainText('Market value $25.00'); await inventoryLink.click(); await expect(page).toHaveURL(/\/admin\/inventory\/[^/]+\/images$/); await expect(page.getByRole('heading', { name: 'Manage images' })).toBeVisible();