Add seller-scoped dashboard aggregation and API, private financial summary DTOs, responsive admin metrics, and authenticated E2E coverage.
This commit is contained in:
@@ -16,6 +16,7 @@ import { registerPublicSearchRoutes } from '../modules/search/routes/public-sear
|
|||||||
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
import { registerPublicSellerRoutes } from '../modules/sellers/routes/public-seller-routes.js';
|
||||||
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
import { registerAdminSaleRoutes } from '../modules/sales/routes/admin-sale-routes.js';
|
||||||
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js';
|
import { registerAdminAcquisitionRoutes } from '../modules/acquisitions/routes/admin-acquisition-routes.js';
|
||||||
|
import { registerAdminDashboardRoutes } from '../modules/dashboard/routes/admin-dashboard-routes.js';
|
||||||
|
|
||||||
export function buildApp(dependencies) {
|
export function buildApp(dependencies) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
@@ -71,6 +72,10 @@ export function buildApp(dependencies) {
|
|||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1/admin',
|
prefix: '/api/v1/admin',
|
||||||
});
|
});
|
||||||
|
app.register(registerAdminDashboardRoutes, {
|
||||||
|
...dependencies,
|
||||||
|
prefix: '/api/v1/admin',
|
||||||
|
});
|
||||||
app.register(registerPublicSearchRoutes, {
|
app.register(registerPublicSearchRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: '/api/v1',
|
prefix: '/api/v1',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { createSaleRepository } from '../modules/sales/repositories/sale-reposit
|
|||||||
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
import { createSaleService } from '../modules/sales/services/sale-service.js';
|
||||||
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js';
|
import { createAcquisitionRepository } from '../modules/acquisitions/repositories/acquisition-repository.js';
|
||||||
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js';
|
import { createAcquisitionService } from '../modules/acquisitions/services/acquisition-service.js';
|
||||||
|
import { createDashboardService } from '../modules/dashboard/services/dashboard-service.js';
|
||||||
|
|
||||||
function parsePort(value) {
|
function parsePort(value) {
|
||||||
const port = Number.parseInt(value ?? '3000', 10);
|
const port = Number.parseInt(value ?? '3000', 10);
|
||||||
@@ -123,6 +124,13 @@ async function startServer() {
|
|||||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||||
saleService: createSaleService({ saleRepository }),
|
saleService: createSaleService({ saleRepository }),
|
||||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
|
dashboardService: createDashboardService({
|
||||||
|
productService: createProductService({ productRepository }),
|
||||||
|
inventoryItemService: createInventoryItemService({ conditionRepository, inventoryItemRepository, productRepository }),
|
||||||
|
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||||
|
saleService: createSaleService({ saleRepository }),
|
||||||
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
|
}),
|
||||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
publicProductService: createPublicProductService({ productRepository }),
|
publicProductService: createPublicProductService({ productRepository }),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { toAdminDashboardDto } from '@pokemon-inventory/contracts/dashboard';
|
||||||
|
|
||||||
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
|
export async function registerAdminDashboardRoutes(app, options) {
|
||||||
|
const {
|
||||||
|
authenticateAdmin = async () => { throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401); },
|
||||||
|
dashboardService,
|
||||||
|
} = 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('/dashboard', async (request) => ({
|
||||||
|
data: toAdminDashboardDto(await dashboardService.get({ sellerId: request.admin.sellerId })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export function createDashboardService({ productService, inventoryItemService, listingService, saleService, acquisitionService }) {
|
||||||
|
return {
|
||||||
|
async get({ sellerId }) {
|
||||||
|
const [products, inventory, listings, sales, acquisitions] = await Promise.all([
|
||||||
|
productService.listBySellerId(sellerId),
|
||||||
|
inventoryItemService.listBySellerId(sellerId),
|
||||||
|
listingService.list({ sellerId }),
|
||||||
|
saleService.list({ sellerId }),
|
||||||
|
acquisitionService.list({ sellerId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeListings = listings.filter(({ status }) => ['AVAILABLE', 'PENDING'].includes(status));
|
||||||
|
const completedSales = sales.filter(({ status }) => status !== 'VOIDED');
|
||||||
|
const total = (values, field) => values.reduce((sum, value) => sum + (value[field] ?? 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts: {
|
||||||
|
products: products.length,
|
||||||
|
inventory: inventory.length,
|
||||||
|
activeInventory: inventory.filter(({ status }) => status === 'ACTIVE').length,
|
||||||
|
soldInventory: inventory.filter(({ status }) => status === 'SOLD').length,
|
||||||
|
availableListings: activeListings.length,
|
||||||
|
sales: completedSales.length,
|
||||||
|
acquisitions: acquisitions.length,
|
||||||
|
missingImages: listings.filter(({ imageCount }) => imageCount < 1).length,
|
||||||
|
},
|
||||||
|
financials: {
|
||||||
|
totalAcquisitionCostMinor: total(acquisitions, 'costBasisMinor'),
|
||||||
|
totalAskingValueMinor: total(activeListings, 'askingPriceMinor'),
|
||||||
|
totalNetSaleProceedsMinor: total(completedSales, 'netProceedsMinor'),
|
||||||
|
totalProfitMinor: total(completedSales, 'profitMinor'),
|
||||||
|
},
|
||||||
|
recentSales: completedSales.slice(0, 5),
|
||||||
|
recentAcquisitions: acquisitions.slice(0, 5),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createDashboardService } from '../../src/modules/dashboard/services/dashboard-service.js';
|
||||||
|
|
||||||
|
test('aggregates seller dashboard counts and financial totals', async () => {
|
||||||
|
const service = createDashboardService({
|
||||||
|
productService: { async listBySellerId() { return [{ id: 'product-1' }]; } },
|
||||||
|
inventoryItemService: { async listBySellerId() { return [{ status: 'ACTIVE' }, { status: 'SOLD' }]; } },
|
||||||
|
listingService: { async list() { return [{ status: 'AVAILABLE', askingPriceMinor: 2500, imageCount: 1 }]; } },
|
||||||
|
saleService: { async list() { return [{ status: 'COMPLETED', netProceedsMinor: 2200, profitMinor: 700 }]; } },
|
||||||
|
acquisitionService: { async list() { return [{ costBasisMinor: 1500 }]; } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboard = await service.get({ sellerId: 'seller-1' });
|
||||||
|
assert.deepEqual(dashboard.counts, {
|
||||||
|
products: 1,
|
||||||
|
inventory: 2,
|
||||||
|
activeInventory: 1,
|
||||||
|
soldInventory: 1,
|
||||||
|
availableListings: 1,
|
||||||
|
sales: 1,
|
||||||
|
acquisitions: 1,
|
||||||
|
missingImages: 0,
|
||||||
|
});
|
||||||
|
assert.deepEqual(dashboard.financials, {
|
||||||
|
totalAcquisitionCostMinor: 1500,
|
||||||
|
totalAskingValueMinor: 2500,
|
||||||
|
totalNetSaleProceedsMinor: 2200,
|
||||||
|
totalProfitMinor: 700,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildApp } from '../../src/app/build-app.js';
|
||||||
|
|
||||||
|
test('returns a seller-scoped dashboard', async (testContext) => {
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
dashboardService: { async get({ sellerId }) { assert.equal(sellerId, 'seller-1'); return { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] }; } },
|
||||||
|
});
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/dashboard' });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.deepEqual(response.json().data, { counts: {}, financials: {}, recentSales: [], recentAcquisitions: [] });
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { createAdminInventory, createAdminProduct, listAdminCategories, listAdmi
|
|||||||
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
|
import { createAdminListing, listAdminListings, markListingPending, publishAdminListing } from '@/services/admin-listing-service.js';
|
||||||
import { createAdminSale, listAdminSales, voidAdminSale } from '@/services/admin-sale-service.js';
|
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';
|
||||||
|
|
||||||
const { principal } = useAuth();
|
const { principal } = useAuth();
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
@@ -15,6 +16,7 @@ const inventory = ref([]);
|
|||||||
const listings = ref([]);
|
const listings = ref([]);
|
||||||
const sales = ref([]);
|
const sales = ref([]);
|
||||||
const acquisitions = ref([]);
|
const acquisitions = ref([]);
|
||||||
|
const dashboard = ref(null);
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const conditions = ref([]);
|
const conditions = ref([]);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -45,7 +47,7 @@ function csrfToken() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions()]);
|
[products.value, inventory.value, categories.value, conditions.value, listings.value, sales.value, acquisitions.value, dashboard.value] = await Promise.all([listAdminProducts(), listAdminInventory(), listAdminCategories(), listAdminConditions(), listAdminListings(), listAdminSales(), listAdminAcquisitions(), getAdminDashboard()]);
|
||||||
} catch (requestError) { error.value = requestError; }
|
} catch (requestError) { error.value = requestError; }
|
||||||
finally { isLoading.value = false; }
|
finally { isLoading.value = false; }
|
||||||
}
|
}
|
||||||
@@ -154,6 +156,20 @@ onMounted(load);
|
|||||||
<p v-if="isLoading" class="state state--loading" role="status">Loading inventory...</p>
|
<p v-if="isLoading" class="state state--loading" role="status">Loading inventory...</p>
|
||||||
<p v-if="error" class="state state--error" role="alert">{{ error.message }}</p>
|
<p v-if="error" class="state state--error" role="alert">{{ error.message }}</p>
|
||||||
|
|
||||||
|
<section v-if="dashboard" class="dashboard-summary" aria-labelledby="dashboard-summary-title">
|
||||||
|
<h2 id="dashboard-summary-title">At a glance</h2>
|
||||||
|
<dl class="dashboard-summary__grid">
|
||||||
|
<div><dt>Active inventory</dt><dd>{{ dashboard.counts.activeInventory }}</dd></div>
|
||||||
|
<div><dt>Available listings</dt><dd>{{ dashboard.counts.availableListings }}</dd></div>
|
||||||
|
<div><dt>Sold inventory</dt><dd>{{ dashboard.counts.soldInventory }}</dd></div>
|
||||||
|
<div><dt>Missing images</dt><dd>{{ dashboard.counts.missingImages }}</dd></div>
|
||||||
|
<div><dt>Acquisition cost</dt><dd>${{ (dashboard.financials.totalAcquisitionCostMinor / 100).toFixed(2) }}</dd></div>
|
||||||
|
<div><dt>Asking value</dt><dd>${{ (dashboard.financials.totalAskingValueMinor / 100).toFixed(2) }}</dd></div>
|
||||||
|
<div><dt>Net proceeds</dt><dd>${{ (dashboard.financials.totalNetSaleProceedsMinor / 100).toFixed(2) }}</dd></div>
|
||||||
|
<div><dt>Profit</dt><dd>${{ (dashboard.financials.totalProfitMinor / 100).toFixed(2) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="admin-workspace-grid">
|
<div class="admin-workspace-grid">
|
||||||
<form class="showcase-panel" @submit.prevent="createProduct">
|
<form class="showcase-panel" @submit.prevent="createProduct">
|
||||||
<h2>Add product</h2>
|
<h2>Add product</h2>
|
||||||
@@ -275,3 +291,12 @@ onMounted(load);
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dashboard-summary { margin: var(--space-6) 0; }
|
||||||
|
.dashboard-summary h2 { margin-bottom: var(--space-3); }
|
||||||
|
.dashboard-summary__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--space-3); margin: 0; }
|
||||||
|
.dashboard-summary__grid div { border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-3); background: var(--color-surface); }
|
||||||
|
.dashboard-summary__grid dt { color: var(--color-text-muted); font-size: var(--text-sm); }
|
||||||
|
.dashboard-summary__grid dd { margin: var(--space-2) 0 0; font-size: var(--text-xl); font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1';
|
||||||
|
|
||||||
|
export async function getAdminDashboard() {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/admin/dashboard`, { credentials: 'include' });
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(payload?.error?.message ?? 'Dashboard request failed.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ test('signs in and publishes a listing after adding an inventory image', async (
|
|||||||
await expect(page).toHaveURL(/\/admin$/);
|
await expect(page).toHaveURL(/\/admin$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Collector workspace' })).toBeVisible();
|
||||||
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0);
|
await expect(page.getByRole('status', { name: 'Loading inventory...' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: 'At a glance' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Active inventory')).toBeVisible();
|
||||||
|
|
||||||
const category = page.locator('#product-category option').nth(1);
|
const category = page.locator('#product-category option').nth(1);
|
||||||
await expect(category).toBeAttached();
|
await expect(category).toBeAttached();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function toAdminDashboardDto(dashboard) {
|
||||||
|
return {
|
||||||
|
counts: dashboard.counts,
|
||||||
|
financials: dashboard.financials,
|
||||||
|
recentSales: dashboard.recentSales.map(({ sale, saleItem, inventoryItem, productName, netProceedsMinor, profitMinor }) => ({
|
||||||
|
id: sale.id,
|
||||||
|
productName: productName ?? inventoryItem.id,
|
||||||
|
saleDate: sale.saleDate,
|
||||||
|
soldPriceMinor: sale.soldPriceMinor,
|
||||||
|
netProceedsMinor,
|
||||||
|
profitMinor,
|
||||||
|
inventoryItemId: saleItem.inventoryItemId,
|
||||||
|
})),
|
||||||
|
recentAcquisitions: dashboard.recentAcquisitions.map((acquisition) => ({
|
||||||
|
id: acquisition.id,
|
||||||
|
inventoryItemId: acquisition.inventoryItemId,
|
||||||
|
date: acquisition.date ?? null,
|
||||||
|
costBasisMinor: acquisition.costBasisMinor ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./auth": "./auth.js",
|
"./auth": "./auth.js",
|
||||||
"./acquisition": "./acquisition.js",
|
"./acquisition": "./acquisition.js",
|
||||||
|
"./dashboard": "./dashboard.js",
|
||||||
"./inventory": "./inventory.js",
|
"./inventory": "./inventory.js",
|
||||||
"./image": "./image.js",
|
"./image": "./image.js",
|
||||||
"./listing": "./listing.js",
|
"./listing": "./listing.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user