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 @@ + + + diff --git a/apps/web/src/pages/AdminShell.vue b/apps/web/src/pages/AdminShell.vue index 4d22b44..e6ea3e3 100644 --- a/apps/web/src/pages/AdminShell.vue +++ b/apps/web/src/pages/AdminShell.vue @@ -19,6 +19,7 @@ async function logout() {

Inventory