Add the admin pricing observation and valuation form, secure valuation writes with CSRF, and extend authenticated browser coverage.
This commit is contained in:
@@ -36,6 +36,7 @@ export async function registerAdminPricingRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/inventory/:id/valuation', async (request, reply) => {
|
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 });
|
const valuation = await pricingService.calculate({ sellerId: request.admin.sellerId, inventoryItemId: request.params.id });
|
||||||
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
return reply.code(200).send({ data: toAdminMarketValuationDto(valuation) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ test('accepts a protected pricing observation and valuation request', async (tes
|
|||||||
assert.equal(observationResponse.statusCode, 201);
|
assert.equal(observationResponse.statusCode, 201);
|
||||||
assert.equal(valuationResponse.statusCode, 200);
|
assert.equal(valuationResponse.statusCode, 200);
|
||||||
assert.equal(valuationResponse.json().data.valueMinor, 2500);
|
assert.equal(valuationResponse.json().data.valueMinor, 2500);
|
||||||
assert.equal(csrfChecks, 1);
|
assert.equal(csrfChecks, 2);
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,7 @@ import { createAdminListing, listAdminListings, markListingPending, publishAdmin
|
|||||||
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
||||||
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
|
import { createAdminAcquisition, listAdminAcquisitions } from '@/services/admin-acquisition-service.js';
|
||||||
import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
|
import { getAdminDashboard } from '@/services/admin-dashboard-service.js';
|
||||||
|
import { addAdminPricingObservation, calculateAdminValuation } from '@/services/admin-pricing-service.js';
|
||||||
|
|
||||||
const { principal } = useAuth();
|
const { principal } = useAuth();
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
@@ -17,6 +18,7 @@ const listings = ref([]);
|
|||||||
const sales = ref([]);
|
const sales = ref([]);
|
||||||
const acquisitions = ref([]);
|
const acquisitions = ref([]);
|
||||||
const dashboard = ref(null);
|
const dashboard = ref(null);
|
||||||
|
const valuations = ref({});
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const conditions = ref([]);
|
const conditions = ref([]);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -38,6 +40,10 @@ const acquisitionCost = ref('');
|
|||||||
const acquisitionShipping = ref('');
|
const acquisitionShipping = ref('');
|
||||||
const acquisitionTax = ref('');
|
const acquisitionTax = ref('');
|
||||||
const acquisitionDate = ref(new Date().toISOString().slice(0, 10));
|
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({});
|
const voidReasons = ref({});
|
||||||
|
|
||||||
function csrfToken() {
|
function csrfToken() {
|
||||||
@@ -132,6 +138,19 @@ async function recordAcquisition() {
|
|||||||
finally { isSaving.value = false; }
|
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) {
|
async function voidSale(saleId) {
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -241,6 +260,21 @@ onMounted(load);
|
|||||||
<input id="acquisition-tax" v-model="acquisitionTax" class="fixture-input" type="number" min="0" />
|
<input id="acquisition-tax" v-model="acquisitionTax" class="fixture-input" type="number" min="0" />
|
||||||
<button class="button button--primary" type="submit" :disabled="isSaving">Record acquisition</button>
|
<button class="button button--primary" type="submit" :disabled="isSaving">Record acquisition</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form class="showcase-panel" @submit.prevent="calculatePricing">
|
||||||
|
<h2>Calculate market value</h2>
|
||||||
|
<label class="fixture-label" for="pricing-inventory">Inventory copy</label>
|
||||||
|
<select id="pricing-inventory" v-model="pricingInventoryId" class="fixture-input" required>
|
||||||
|
<option value="" disabled>Select a copy</option>
|
||||||
|
<option v-for="item in inventory" :key="item.id" :value="item.id">{{ item.id }}</option>
|
||||||
|
</select>
|
||||||
|
<label class="fixture-label" for="pricing-provider">Provider</label>
|
||||||
|
<input id="pricing-provider" v-model="pricingProvider" class="fixture-input" required />
|
||||||
|
<label class="fixture-label" for="pricing-source">Source identifier</label>
|
||||||
|
<input id="pricing-source" v-model="pricingSource" class="fixture-input" required />
|
||||||
|
<label class="fixture-label" for="pricing-price">Observed price (cents)</label>
|
||||||
|
<input id="pricing-price" v-model="pricingPrice" class="fixture-input" type="number" min="1" required />
|
||||||
|
<button class="button button--primary" type="submit" :disabled="isSaving">Add observation and calculate</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="admin-list" aria-labelledby="products-title">
|
<section class="admin-list" aria-labelledby="products-title">
|
||||||
@@ -252,6 +286,9 @@ onMounted(load);
|
|||||||
<h2 id="inventory-title">Physical inventory</h2>
|
<h2 id="inventory-title">Physical inventory</h2>
|
||||||
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
|
<p v-if="!inventory.length && !isLoading" class="state state--empty">No physical copies yet.</p>
|
||||||
<ul v-else><li v-for="item in inventory" :key="item.id" :data-product-id="item.productId"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
|
<ul v-else><li v-for="item in inventory" :key="item.id" :data-product-id="item.productId"><span>{{ item.id }}</span> · {{ item.status }} <RouterLink :to="{ name: 'admin-image-manager', params: { id: item.id } }">Manage images</RouterLink></li></ul>
|
||||||
|
<ul v-if="Object.keys(valuations).length" aria-label="Market valuations">
|
||||||
|
<li v-for="(valuation, inventoryItemId) in valuations" :key="inventoryItemId">{{ inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }} · {{ valuation.confidence }} confidence · {{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? '' : 's' }}</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="admin-list" aria-labelledby="listings-title">
|
<section class="admin-list" aria-labelledby="listings-title">
|
||||||
<h2 id="listings-title">Listings</h2>
|
<h2 id="listings-title">Listings</h2>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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.getByLabel('Tax (cents)').fill('50');
|
||||||
await page.getByRole('button', { name: 'Record acquisition' }).click();
|
await page.getByRole('button', { name: 'Record acquisition' }).click();
|
||||||
await expect(page.locator('section[aria-labelledby="acquisitions-title"] li').first()).toContainText('Basis $11.50');
|
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 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user