feat: add seller settings
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 01:14:57 -04:00
parent 24f51212e5
commit cf61ec3c2c
20 changed files with 401 additions and 2 deletions
+5
View File
@@ -21,6 +21,7 @@ import { registerAdminDashboardRoutes } from "../modules/dashboard/routes/admin-
import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pricing-routes.js";
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
export function buildApp(dependencies) {
const app = Fastify();
@@ -93,6 +94,10 @@ export function buildApp(dependencies) {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerAdminSettingsRoutes, {
...dependencies,
prefix: "/api/v1/admin",
});
app.register(registerPublicSearchRoutes, {
...dependencies,
prefix: "/api/v1",
+6
View File
@@ -53,6 +53,8 @@ import { createPricingProviderRegistry } from "../modules/pricing/providers/pric
import { createFixturePricingProvider } from "../modules/pricing/providers/fixture-pricing-provider.js";
import { createHttpPricingProvider } from "../modules/pricing/providers/http-pricing-provider.js";
import { createAdminExportService } from "../modules/exports/services/admin-export-service.js";
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
function parsePort(value) {
const port = Number.parseInt(value ?? "3000", 10);
@@ -90,6 +92,9 @@ async function startServer() {
const providerMappingRepository = createProviderMappingRepository(
database.db,
);
const sellerSettingsService = createSellerSettingsService({
sellerSettingsRepository: createSellerSettingsRepository(database.db),
});
const providers = [];
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
providers.push(createFixturePricingProvider());
@@ -163,6 +168,7 @@ async function startServer() {
adminExportService: createAdminExportService({
inventoryItemService,
}),
sellerSettingsService,
catalogLookupRepository,
publicImageService,
publicInventoryService: createPublicInventoryService({
@@ -61,6 +61,14 @@ export const sellers = pgTable(
(table) => [uniqueIndex("sellers_user_id_unique").on(table.userId)],
);
export const sellerSettings = pgTable("seller_settings", {
sellerId: uuid("seller_id")
.primaryKey()
.references(() => sellers.id, { onDelete: "cascade" }),
settings: jsonb("settings").default({}).notNull(),
...timestamps,
});
export const sessions = pgTable(
"sessions",
{
@@ -0,0 +1,24 @@
import { eq } from 'drizzle-orm';
import { sellerSettings } from '../../../infrastructure/database/schema.js';
export function createSellerSettingsRepository(db) {
return {
async findBySellerId(sellerId) {
const [record] = await db.select().from(sellerSettings).where(eq(sellerSettings.sellerId, sellerId));
return record ?? null;
},
async upsert(sellerId, settings) {
const [record] = await db
.insert(sellerSettings)
.values({ sellerId, settings })
.onConflictDoUpdate({
target: sellerSettings.sellerId,
set: { settings, updatedAt: new Date() },
})
.returning();
return record;
},
};
}
@@ -0,0 +1,50 @@
import { sellerSettingsSchema } from '@pokemon-inventory/validation/settings';
import { createApplicationError } from '../../../shared/errors/application-error.js';
export async function registerAdminSettingsRoutes(app, options) {
const {
authenticateAdmin = async () => {
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
},
authorizeUnsafeRequest,
sessionCookieName = 'pokemon_inventory_session',
sellerSettingsService,
} = 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('/settings', async (request) => ({
data: await sellerSettingsService.get({ sellerId: request.admin.sellerId }),
}));
app.patch('/settings', async (request, reply) => {
await authorizeUnsafeRequest?.(
request.cookies[sessionCookieName],
request.headers['x-csrf-token'],
);
const result = sellerSettingsSchema.safeParse(request.body);
if (!result.success) {
return reply.code(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed.',
details: result.error.issues.map(({ message, path }) => ({ message, path })),
},
});
}
return reply.send({
data: await sellerSettingsService.update({
sellerId: request.admin.sellerId,
settings: result.data,
}),
});
});
}
@@ -0,0 +1,27 @@
import { sellerSettingsSchema } from '@pokemon-inventory/validation/settings';
export function createSellerSettingsService({ sellerSettingsRepository }) {
return {
async get({ sellerId }) {
const record = await sellerSettingsRepository.findBySellerId(sellerId);
return record?.settings ?? {};
},
async update({ sellerId, settings }) {
const validated = sellerSettingsSchema.parse(settings);
const current = await this.get({ sellerId });
const merged = mergeSettings(current, validated);
const record = await sellerSettingsRepository.upsert(sellerId, merged);
return record.settings;
},
};
}
function mergeSettings(current, update) {
return Object.fromEntries(
Object.entries({ ...current, ...update }).map(([category, values]) => [
category,
{ ...(current[category] ?? {}), ...values },
]),
);
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSellerSettingsService } from '../../src/modules/settings/services/seller-settings-service.js';
test('merges validated seller settings without replacing other categories', async () => {
let stored = null;
const service = createSellerSettingsService({
sellerSettingsRepository: {
async findBySellerId() { return stored; },
async upsert(sellerId, settings) {
stored = { sellerId, settings };
return stored;
},
},
});
await service.update({
sellerId: 'seller-1',
settings: { pricing: { defaultStrategy: 'median' }, general: { publicContactEmail: 'owner@example.test' } },
});
const result = await service.update({
sellerId: 'seller-1',
settings: { inventory: { maxImagesPerItem: 12 } },
});
assert.deepEqual(result, {
pricing: { defaultStrategy: 'median' },
general: { publicContactEmail: 'owner@example.test' },
inventory: { maxImagesPerItem: 12 },
});
});
test('rejects unknown or invalid settings', async () => {
const service = createSellerSettingsService({
sellerSettingsRepository: { async findBySellerId() { return null; } },
});
await assert.rejects(
() => service.update({ sellerId: 'seller-1', settings: { secrets: { token: 'nope' } } }),
);
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildApp } from '../../src/app/build-app.js';
function createTestApp({ authorizeUnsafeRequest } = {}) {
let settings = { pricing: { defaultStrategy: 'median' } };
return buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
authorizeUnsafeRequest,
sellerSettingsService: {
async get() { return settings; },
async update({ settings: nextSettings }) {
settings = { ...settings, ...nextSettings };
return settings;
},
},
});
}
test('reads and updates seller settings', async (testContext) => {
const app = createTestApp();
testContext.after(() => app.close());
const readResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/settings' });
const updateResponse = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { inventory: { maxImagesPerItem: 12 } },
});
assert.equal(readResponse.statusCode, 200);
assert.deepEqual(readResponse.json().data, { pricing: { defaultStrategy: 'median' } });
assert.equal(updateResponse.statusCode, 200);
assert.deepEqual(updateResponse.json().data, {
pricing: { defaultStrategy: 'median' },
inventory: { maxImagesPerItem: 12 },
});
});
test('protects settings updates with CSRF validation', async (testContext) => {
const app = createTestApp({
authorizeUnsafeRequest: async () => {
const error = new Error('CSRF validation failed.');
error.code = 'FORBIDDEN';
error.statusCode = 403;
throw error;
},
});
testContext.after(() => app.close());
const response = await app.inject({
method: 'PATCH',
url: '/api/v1/admin/settings',
payload: { pricing: { defaultStrategy: 'median' } },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error.code, 'FORBIDDEN');
});
@@ -0,0 +1,5 @@
import AdminSettingsPage from './AdminSettingsPage.vue';
export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage };
export const SettingsForm = {};
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
const settings = reactive({
general: { publicContactEmail: '', publicContactPhone: '' },
inventory: { maxImagesPerItem: 10 },
pricing: { defaultStrategy: 'median' },
security: { sessionIdleMinutes: 60 },
});
const isLoading = ref(true);
const isSaving = ref(false);
const message = ref('');
const error = ref('');
function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
}
async function load() {
try {
Object.assign(settings, await getAdminSettings());
} catch (requestError) {
error.value = requestError.message;
} finally {
isLoading.value = false;
}
}
async function save() {
isSaving.value = true;
message.value = '';
error.value = '';
try {
Object.assign(settings, await updateAdminSettings(settings, csrfToken()));
message.value = 'Settings saved.';
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="admin-page">
<p class="catalog-kicker">Configuration</p>
<h1>Settings</h1>
<p v-if="isLoading" class="state state--loading" role="status">Loading settings...</p>
<p v-if="message" class="state" role="status">{{ message }}</p>
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
<fieldset class="showcase-panel">
<legend>General</legend>
<label class="fixture-label" for="settings-email">Public contact email</label>
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" />
<label class="fixture-label" for="settings-phone">Public contact phone</label>
<input id="settings-phone" v-model="settings.general.publicContactPhone" class="fixture-input" />
</fieldset>
<fieldset class="showcase-panel">
<legend>Inventory</legend>
<label class="fixture-label" for="settings-images">Maximum images per item</label>
<input id="settings-images" v-model.number="settings.inventory.maxImagesPerItem" class="fixture-input" type="number" min="1" max="100" />
</fieldset>
<fieldset class="showcase-panel">
<legend>Pricing</legend>
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
<select id="settings-strategy" v-model="settings.pricing.defaultStrategy" class="fixture-input">
<option value="median">Median</option>
<option value="average">Average</option>
<option value="weighted_average">Weighted average</option>
</select>
</fieldset>
<fieldset class="showcase-panel">
<legend>Security</legend>
<label class="fixture-label" for="settings-idle">Session idle minutes</label>
<input id="settings-idle" v-model.number="settings.security.sessionIdleMinutes" class="fixture-input" type="number" min="5" max="43200" />
</fieldset>
<button class="button button--primary" type="submit" :disabled="isSaving">Save settings</button>
</form>
</section>
</template>
+1
View File
@@ -19,6 +19,7 @@ async function logout() {
<h1>Inventory</h1>
<nav aria-label="Admin navigation">
<a href="/admin">Overview</a>
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
</nav>
<div class="admin-sidebar__footer">
<span>{{ principal?.email }}</span>
+2
View File
@@ -8,6 +8,7 @@ import AdminShell from '@/pages/AdminShell.vue';
import LoginPage from '@/pages/LoginPage.vue';
import { useAuth } from '@/composables/useAuth.js';
import AdminImagePage from '@/pages/AdminImagePage.vue';
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
const router = createRouter({
history: createWebHistory(),
@@ -22,6 +23,7 @@ const router = createRouter({
children: [
{ path: '', name: 'admin-home', component: AdminHomePage },
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
],
},
],
@@ -0,0 +1,24 @@
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 payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error?.message ?? 'Settings request failed.');
return payload?.data;
}
export function getAdminSettings() {
return request('/admin/settings');
}
export function updateAdminSettings(settings, csrfToken) {
return request('/admin/settings', {
method: 'PATCH',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify(settings),
});
}
+20
View File
@@ -116,4 +116,24 @@ test('updates selected admin inventory tags through the bulk API', async () => {
} finally {
globalThis.fetch = originalFetch;
}
});
test('updates admin settings through the CSRF-protected API', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options) => {
assert.equal(url, '/api/v1/admin/settings');
assert.equal(options.method, 'PATCH');
assert.equal(options.headers['x-csrf-token'], 'csrf-token');
assert.deepEqual(JSON.parse(options.body), { pricing: { defaultStrategy: 'median' } });
return { ok: true, async json() { return { data: { pricing: { defaultStrategy: 'median' } } }; } };
};
try {
const { updateAdminSettings } = await import('../src/services/admin-settings-service.js');
assert.deepEqual(await updateAdminSettings({ pricing: { defaultStrategy: 'median' } }, 'csrf-token'), {
pricing: { defaultStrategy: 'median' },
});
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,6 @@
CREATE TABLE "seller_settings" (
"seller_id" uuid PRIMARY KEY REFERENCES "sellers"("id") ON DELETE CASCADE,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL
);
+7
View File
@@ -162,6 +162,13 @@
"when": 1790496000000,
"tag": "0022_product_tags",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1790582400000,
"tag": "0023_seller_settings",
"breakpoints": true
}
]
}
+2
View File
@@ -102,6 +102,8 @@ GET /api/v1/admin/dashboard
GET /api/v1/admin/exports/inventory.json
GET /api/v1/admin/exports/inventory.csv
GET /api/v1/admin/exports/inventory.xls
GET /api/v1/admin/settings
PATCH /api/v1/admin/settings
GET /api/v1/admin/saved-views
POST /api/v1/admin/saved-views
+7
View File
@@ -109,6 +109,13 @@ As described above.
- active
- sort
## Seller Settings
`seller_settings` stores one JSON settings document per Seller. Supported
categories are general contact details, inventory image limits, pricing
strategy, and session idle duration. Settings are private to the Seller and
are validated through the admin API allowlist.
`conditions`:
- id
- condition_profile_id
+3 -2
View File
@@ -126,7 +126,7 @@ Verified locally: `npm test` passes 116 tests with no failures; worker tests, we
Complete production configuration and operational maturity around the implemented vertical slices.
1. Configure and validate an approved external pricing provider and provider-specific mapping data.
2. Add richer exports/settings, broader accessibility/manual coverage, and any remaining integration edge cases.
2. Add asynchronous large exports, broader accessibility/manual coverage, and any remaining integration edge cases.
Acceptance criteria: production provider credentials are secret-managed, migrations and backups are exercised in the target environment, and CI runs integration, authenticated E2E, web, build, Storybook build, and Storybook interaction/accessibility gates.
@@ -136,7 +136,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema
Completed vertical slices include authentication, listings and image prerequisites, acquisitions, sales and audited corrections, dashboard metrics/alerts, pricing observations and quality handling, provider mappings, durable pricing refresh jobs, public search/detail/image states, admin workflows, CI integration/E2E gates, production Docker packaging, rollback/health checks, PostgreSQL backup verification, restore drill scripts, image-storage restore support, and deployment smoke checks.
Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/settings, approved external provider credentials/adapters, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation.
Remaining roadmap areas are bulk status actions, asynchronous large exports, approved external provider credentials/adapters, broader accessibility/manual coverage, and deployment-specific NAS/TLS validation.
## Known Gaps
@@ -152,6 +152,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett
- Admin inventory JSON export is implemented at `GET /api/v1/admin/exports/inventory.json` and serializes seller-scoped admin DTOs as a downloadable attachment.
- Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs.
- Admin inventory Excel-compatible export is implemented at `GET /api/v1/admin/exports/inventory.xls` using the XML workbook format and seller-scoped admin DTOs.
- Seller-scoped admin settings are implemented through `GET/PATCH /api/v1/admin/settings` with strict category allowlists and server-side partial-update merging.
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
- `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const sellerSettingsSchema = z.object({
general: z.object({
publicContactEmail: z.string().trim().email().optional(),
publicContactPhone: z.string().trim().max(50).optional(),
}).strict().optional(),
inventory: z.object({
maxImagesPerItem: z.int().min(1).max(100).optional(),
}).strict().optional(),
pricing: z.object({
defaultStrategy: z.enum(['average', 'median', 'weighted_average']).optional(),
}).strict().optional(),
security: z.object({
sessionIdleMinutes: z.int().min(5).max(43200).optional(),
}).strict().optional(),
}).strict();