diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js
index 6ec95ff..af1548f 100644
--- a/apps/api/src/app/build-app.js
+++ b/apps/api/src/app/build-app.js
@@ -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",
diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js
index eaa261f..60b3c38 100644
--- a/apps/api/src/app/server.js
+++ b/apps/api/src/app/server.js
@@ -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({
diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js
index 979ec46..25136b7 100644
--- a/apps/api/src/infrastructure/database/schema.js
+++ b/apps/api/src/infrastructure/database/schema.js
@@ -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",
{
diff --git a/apps/api/src/modules/settings/repositories/seller-settings-repository.js b/apps/api/src/modules/settings/repositories/seller-settings-repository.js
new file mode 100644
index 0000000..9df1de4
--- /dev/null
+++ b/apps/api/src/modules/settings/repositories/seller-settings-repository.js
@@ -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;
+ },
+ };
+}
diff --git a/apps/api/src/modules/settings/routes/admin-settings-routes.js b/apps/api/src/modules/settings/routes/admin-settings-routes.js
new file mode 100644
index 0000000..f78b974
--- /dev/null
+++ b/apps/api/src/modules/settings/routes/admin-settings-routes.js
@@ -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,
+ }),
+ });
+ });
+}
diff --git a/apps/api/src/modules/settings/services/seller-settings-service.js b/apps/api/src/modules/settings/services/seller-settings-service.js
new file mode 100644
index 0000000..f3fe851
--- /dev/null
+++ b/apps/api/src/modules/settings/services/seller-settings-service.js
@@ -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 },
+ ]),
+ );
+}
diff --git a/apps/api/tests/application/seller-settings-service.test.js b/apps/api/tests/application/seller-settings-service.test.js
new file mode 100644
index 0000000..892e326
--- /dev/null
+++ b/apps/api/tests/application/seller-settings-service.test.js
@@ -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' } } }),
+ );
+});
diff --git a/apps/api/tests/http/admin-settings-routes.test.js b/apps/api/tests/http/admin-settings-routes.test.js
new file mode 100644
index 0000000..f33556b
--- /dev/null
+++ b/apps/api/tests/http/admin-settings-routes.test.js
@@ -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');
+});
diff --git a/apps/web/src/pages/AdminSettingsPage.stories.js b/apps/web/src/pages/AdminSettingsPage.stories.js
new file mode 100644
index 0000000..c1d9ea6
--- /dev/null
+++ b/apps/web/src/pages/AdminSettingsPage.stories.js
@@ -0,0 +1,5 @@
+import AdminSettingsPage from './AdminSettingsPage.vue';
+
+export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage };
+
+export const SettingsForm = {};
diff --git a/apps/web/src/pages/AdminSettingsPage.vue b/apps/web/src/pages/AdminSettingsPage.vue
new file mode 100644
index 0000000..7130741
--- /dev/null
+++ b/apps/web/src/pages/AdminSettingsPage.vue
@@ -0,0 +1,85 @@
+
+
+
+ Configuration Loading settings... {{ message }} {{ error }}Settings
+