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;
}
});