@@ -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",
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -117,3 +117,23 @@ test('updates selected admin inventory tags through the bulk API', async () => {
|
||||
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
|
||||
);
|
||||
@@ -162,6 +162,13 @@
|
||||
"when": 1790496000000,
|
||||
"tag": "0022_product_tags",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1790582400000,
|
||||
"tag": "0023_seller_settings",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user