@@ -22,6 +22,7 @@ import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pric
|
|||||||
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
import { registerAdminProviderMappingRoutes } from "../modules/pricing/routes/admin-provider-mapping-routes.js";
|
||||||
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
|
import { registerAdminExportRoutes } from "../modules/exports/routes/admin-export-routes.js";
|
||||||
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
|
import { registerAdminSettingsRoutes } from "../modules/settings/routes/admin-settings-routes.js";
|
||||||
|
import { registerAdminSavedViewRoutes } from "../modules/search/routes/admin-saved-view-routes.js";
|
||||||
|
|
||||||
export function buildApp(dependencies) {
|
export function buildApp(dependencies) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
@@ -98,6 +99,10 @@ export function buildApp(dependencies) {
|
|||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: "/api/v1/admin",
|
prefix: "/api/v1/admin",
|
||||||
});
|
});
|
||||||
|
app.register(registerAdminSavedViewRoutes, {
|
||||||
|
...dependencies,
|
||||||
|
prefix: "/api/v1/admin",
|
||||||
|
});
|
||||||
app.register(registerPublicSearchRoutes, {
|
app.register(registerPublicSearchRoutes, {
|
||||||
...dependencies,
|
...dependencies,
|
||||||
prefix: "/api/v1",
|
prefix: "/api/v1",
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import { createAdminExportService } from "../modules/exports/services/admin-expo
|
|||||||
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
|
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
|
||||||
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
|
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.js";
|
||||||
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
|
import { createSellerSettingsService } from "../modules/settings/services/seller-settings-service.js";
|
||||||
|
import { createSavedViewRepository } from "../modules/search/repositories/saved-view-repository.js";
|
||||||
|
import { createSavedViewService } from "../modules/search/services/saved-view-service.js";
|
||||||
|
|
||||||
function parsePort(value) {
|
function parsePort(value) {
|
||||||
const port = Number.parseInt(value ?? "3000", 10);
|
const port = Number.parseInt(value ?? "3000", 10);
|
||||||
@@ -96,6 +98,9 @@ async function startServer() {
|
|||||||
const sellerSettingsService = createSellerSettingsService({
|
const sellerSettingsService = createSellerSettingsService({
|
||||||
sellerSettingsRepository: createSellerSettingsRepository(database.db),
|
sellerSettingsRepository: createSellerSettingsRepository(database.db),
|
||||||
});
|
});
|
||||||
|
const savedViewService = createSavedViewService({
|
||||||
|
savedViewRepository: createSavedViewRepository(database.db),
|
||||||
|
});
|
||||||
const providers = [];
|
const providers = [];
|
||||||
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
|
||||||
providers.push(createFixturePricingProvider());
|
providers.push(createFixturePricingProvider());
|
||||||
@@ -171,6 +176,7 @@ async function startServer() {
|
|||||||
inventoryItemService,
|
inventoryItemService,
|
||||||
}),
|
}),
|
||||||
sellerSettingsService,
|
sellerSettingsService,
|
||||||
|
savedViewService,
|
||||||
catalogLookupRepository,
|
catalogLookupRepository,
|
||||||
publicImageService,
|
publicImageService,
|
||||||
publicInventoryService: createPublicInventoryService({
|
publicInventoryService: createPublicInventoryService({
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ export const inventoryExportJobs = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const savedViews = pgTable(
|
||||||
|
"saved_views",
|
||||||
|
{
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
sellerId: uuid("seller_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sellers.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
filters: jsonb("filters").default({}).notNull(),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("saved_views_seller_name_unique").on(table.sellerId, table.name)],
|
||||||
|
);
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable(
|
||||||
"sessions",
|
"sessions",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { savedViews } from '../../../infrastructure/database/schema.js';
|
||||||
|
|
||||||
|
export function createSavedViewRepository(db) {
|
||||||
|
return {
|
||||||
|
async listBySellerId(sellerId) {
|
||||||
|
return db.select().from(savedViews).where(eq(savedViews.sellerId, sellerId)).orderBy(asc(savedViews.name));
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(sellerId, view) {
|
||||||
|
const [created] = await db.insert(savedViews).values({ sellerId, ...view }).returning();
|
||||||
|
return created;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateForSeller(id, sellerId, view) {
|
||||||
|
const [updated] = await db.update(savedViews).set({ ...view, updatedAt: new Date() }).where(and(
|
||||||
|
eq(savedViews.id, id),
|
||||||
|
eq(savedViews.sellerId, sellerId),
|
||||||
|
)).returning();
|
||||||
|
return updated ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteForSeller(id, sellerId) {
|
||||||
|
const [deleted] = await db.delete(savedViews).where(and(
|
||||||
|
eq(savedViews.id, id),
|
||||||
|
eq(savedViews.sellerId, sellerId),
|
||||||
|
)).returning();
|
||||||
|
return deleted ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { savedViewSchema } from '@pokemon-inventory/validation/saved-view';
|
||||||
|
|
||||||
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
|
export async function registerAdminSavedViewRoutes(app, options) {
|
||||||
|
const {
|
||||||
|
authenticateAdmin = async () => {
|
||||||
|
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||||
|
},
|
||||||
|
authorizeUnsafeRequest,
|
||||||
|
sessionCookieName = 'pokemon_inventory_session',
|
||||||
|
savedViewService,
|
||||||
|
} = 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('/saved-views', async (request) => ({
|
||||||
|
data: await savedViewService.list({ sellerId: request.admin.sellerId }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/saved-views', async (request, reply) => {
|
||||||
|
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||||
|
const result = savedViewSchema.safeParse(request.body);
|
||||||
|
if (!result.success) return reply.code(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.' } });
|
||||||
|
return reply.code(201).send({ data: await savedViewService.create({ sellerId: request.admin.sellerId, view: result.data }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/saved-views/:id', async (request, reply) => {
|
||||||
|
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||||
|
const result = savedViewSchema.safeParse(request.body);
|
||||||
|
if (!result.success) return reply.code(400).send({ error: { code: 'VALIDATION_ERROR', message: 'Request validation failed.' } });
|
||||||
|
return reply.send({ data: await savedViewService.update({ sellerId: request.admin.sellerId, viewId: request.params.id, view: result.data }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/saved-views/:id', async (request, reply) => {
|
||||||
|
await authorizeUnsafeRequest?.(request.cookies[sessionCookieName], request.headers['x-csrf-token']);
|
||||||
|
await savedViewService.delete({ sellerId: request.admin.sellerId, viewId: request.params.id });
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { savedViewSchema } from '@pokemon-inventory/validation/saved-view';
|
||||||
|
|
||||||
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
|
export function createSavedViewService({ savedViewRepository }) {
|
||||||
|
return {
|
||||||
|
async list({ sellerId }) {
|
||||||
|
return savedViewRepository.listBySellerId(sellerId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create({ sellerId, view }) {
|
||||||
|
return savedViewRepository.create(sellerId, savedViewSchema.parse(view));
|
||||||
|
},
|
||||||
|
|
||||||
|
async update({ sellerId, viewId, view }) {
|
||||||
|
const updated = await savedViewRepository.updateForSeller(viewId, sellerId, savedViewSchema.parse(view));
|
||||||
|
if (!updated) throw createApplicationError('SAVED_VIEW_NOT_FOUND', 'Saved view was not found.', 404);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete({ sellerId, viewId }) {
|
||||||
|
const deleted = await savedViewRepository.deleteForSeller(viewId, sellerId);
|
||||||
|
if (!deleted) throw createApplicationError('SAVED_VIEW_NOT_FOUND', 'Saved view was not found.', 404);
|
||||||
|
return deleted;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createSavedViewService } from '../../src/modules/search/services/saved-view-service.js';
|
||||||
|
|
||||||
|
test('creates and merges a seller saved view through the repository', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const service = createSavedViewService({
|
||||||
|
savedViewRepository: {
|
||||||
|
async create(sellerId, view) { calls.push([sellerId, view]); return { id: 'view-1', sellerId, ...view }; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.create({ sellerId: 'seller-1', view: { name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } } });
|
||||||
|
assert.deepEqual(result, { id: 'view-1', sellerId: 'seller-1', name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } });
|
||||||
|
assert.deepEqual(calls, [['seller-1', { name: 'Needs Attention', filters: { status: 'ACTIVE', missingImages: true } }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an unknown saved-view filter value', async () => {
|
||||||
|
const service = createSavedViewService({ savedViewRepository: {} });
|
||||||
|
await assert.rejects(() => service.create({ sellerId: 'seller-1', view: { name: 'Bad', filters: { query: { nested: true } } } }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildApp } from '../../src/app/build-app.js';
|
||||||
|
|
||||||
|
function createTestApp({ authorizeUnsafeRequest } = {}) {
|
||||||
|
const views = [];
|
||||||
|
return buildApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
authorizeUnsafeRequest,
|
||||||
|
savedViewService: {
|
||||||
|
async list() { return views; },
|
||||||
|
async create({ view }) { const result = { id: 'view-1', ...view }; views.push(result); return result; },
|
||||||
|
async update({ view }) { return { id: 'view-1', ...view }; },
|
||||||
|
async delete() { return { id: 'view-1' }; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates, lists, updates, and deletes saved views', async (testContext) => {
|
||||||
|
const app = createTestApp();
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const createResponse = await app.inject({ method: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Needs Attention', filters: { missingImages: true } } });
|
||||||
|
const listResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/saved-views' });
|
||||||
|
const updateResponse = await app.inject({ method: 'PATCH', url: '/api/v1/admin/saved-views/view-1', payload: { name: 'Recent', filters: { sort: 'recent' } } });
|
||||||
|
const deleteResponse = await app.inject({ method: 'DELETE', url: '/api/v1/admin/saved-views/view-1' });
|
||||||
|
|
||||||
|
assert.equal(createResponse.statusCode, 201);
|
||||||
|
assert.equal(listResponse.statusCode, 200);
|
||||||
|
assert.equal(updateResponse.statusCode, 200);
|
||||||
|
assert.equal(deleteResponse.statusCode, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('protects saved-view mutations 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: 'POST', url: '/api/v1/admin/saved-views', payload: { name: 'Recent', filters: {} } });
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminSavedViewsPage from './AdminSavedViewsPage.vue';
|
||||||
|
|
||||||
|
export default { title: 'Pages/AdminSavedViewsPage', component: AdminSavedViewsPage };
|
||||||
|
|
||||||
|
export const SavedViews = {};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { createAdminSavedView, deleteAdminSavedView, listAdminSavedViews } from '@/services/admin-saved-view-service.js';
|
||||||
|
|
||||||
|
const views = ref([]);
|
||||||
|
const name = ref('');
|
||||||
|
const filters = ref('{}');
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const message = ref('');
|
||||||
|
|
||||||
|
function csrfToken() {
|
||||||
|
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
views.value = await listAdminSavedViews();
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = requestError.message;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createView() {
|
||||||
|
isSaving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const view = await createAdminSavedView({ name: name.value, filters: JSON.parse(filters.value) }, csrfToken());
|
||||||
|
views.value.push(view);
|
||||||
|
name.value = '';
|
||||||
|
filters.value = '{}';
|
||||||
|
message.value = 'Saved view created.';
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = requestError instanceof SyntaxError ? 'Filters must be valid JSON.' : requestError.message;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeView(id) {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
await deleteAdminSavedView(id, csrfToken());
|
||||||
|
views.value = views.value.filter((view) => view.id !== id);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = requestError.message;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-page">
|
||||||
|
<p class="catalog-kicker">Inventory management</p>
|
||||||
|
<h1>Saved views</h1>
|
||||||
|
<p v-if="isLoading" class="state state--loading" role="status">Loading saved views...</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="showcase-panel" @submit.prevent="createView">
|
||||||
|
<h2>New saved view</h2>
|
||||||
|
<label class="fixture-label" for="saved-view-name">Name</label>
|
||||||
|
<input id="saved-view-name" v-model="name" class="fixture-input" required maxlength="100" />
|
||||||
|
<label class="fixture-label" for="saved-view-filters">Filters (JSON)</label>
|
||||||
|
<textarea id="saved-view-filters" v-model="filters" class="fixture-input" rows="4" required />
|
||||||
|
<button class="button button--primary" type="submit" :disabled="isSaving">Save view</button>
|
||||||
|
</form>
|
||||||
|
<section class="admin-list" aria-labelledby="saved-views-title">
|
||||||
|
<h2 id="saved-views-title">Your saved views</h2>
|
||||||
|
<p v-if="!views.length && !isLoading" class="state state--empty">No saved views yet.</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="view in views" :key="view.id">
|
||||||
|
<strong>{{ view.name }}</strong> · {{ JSON.stringify(view.filters) }}
|
||||||
|
<button class="button button--secondary" type="button" :disabled="isSaving" @click="removeView(view.id)">Delete</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -20,6 +20,7 @@ async function logout() {
|
|||||||
<nav aria-label="Admin navigation">
|
<nav aria-label="Admin navigation">
|
||||||
<a href="/admin">Overview</a>
|
<a href="/admin">Overview</a>
|
||||||
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
|
<RouterLink :to="{ name: 'admin-settings' }">Settings</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'admin-saved-views' }">Saved views</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-sidebar__footer">
|
<div class="admin-sidebar__footer">
|
||||||
<span>{{ principal?.email }}</span>
|
<span>{{ principal?.email }}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LoginPage from '@/pages/LoginPage.vue';
|
|||||||
import { useAuth } from '@/composables/useAuth.js';
|
import { useAuth } from '@/composables/useAuth.js';
|
||||||
import AdminImagePage from '@/pages/AdminImagePage.vue';
|
import AdminImagePage from '@/pages/AdminImagePage.vue';
|
||||||
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
|
import AdminSettingsPage from '@/pages/AdminSettingsPage.vue';
|
||||||
|
import AdminSavedViewsPage from '@/pages/AdminSavedViewsPage.vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -24,6 +25,7 @@ const router = createRouter({
|
|||||||
{ path: '', name: 'admin-home', component: AdminHomePage },
|
{ path: '', name: 'admin-home', component: AdminHomePage },
|
||||||
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||||
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
|
{ path: 'settings', name: 'admin-settings', component: AdminSettingsPage },
|
||||||
|
{ path: 'saved-views', name: 'admin-saved-views', component: AdminSavedViewsPage },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 ?? 'Saved view request failed.');
|
||||||
|
return payload?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdminSavedViews() { return request('/admin/saved-views'); }
|
||||||
|
export function createAdminSavedView(view, csrfToken) {
|
||||||
|
return request('/admin/saved-views', { method: 'POST', headers: { 'x-csrf-token': csrfToken }, body: JSON.stringify(view) });
|
||||||
|
}
|
||||||
|
export function deleteAdminSavedView(id, csrfToken) {
|
||||||
|
return request(`/admin/saved-views/${id}`, { method: 'DELETE', headers: { 'x-csrf-token': csrfToken } });
|
||||||
|
}
|
||||||
@@ -151,4 +151,24 @@ test('updates admin settings through the CSRF-protected API', async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates an admin saved view through the CSRF-protected API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (url, options) => {
|
||||||
|
assert.equal(url, '/api/v1/admin/saved-views');
|
||||||
|
assert.equal(options.method, 'POST');
|
||||||
|
assert.equal(options.headers['x-csrf-token'], 'csrf-token');
|
||||||
|
assert.deepEqual(JSON.parse(options.body), { name: 'Recent', filters: { sort: 'recent' } });
|
||||||
|
return { ok: true, async json() { return { data: { id: 'view-1', name: 'Recent', filters: { sort: 'recent' } } }; } };
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAdminSavedView } = await import('../src/services/admin-saved-view-service.js');
|
||||||
|
assert.deepEqual(await createAdminSavedView({ name: 'Recent', filters: { sort: 'recent' } }, 'csrf-token'), {
|
||||||
|
id: 'view-1', name: 'Recent', filters: { sort: 'recent' },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "saved_views" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"seller_id" uuid NOT NULL REFERENCES "sellers"("id") ON DELETE CASCADE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"filters" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamptz DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "saved_views_seller_name_unique"
|
||||||
|
ON "saved_views" ("seller_id", "name");
|
||||||
@@ -176,6 +176,13 @@
|
|||||||
"when": 1790668800000,
|
"when": 1790668800000,
|
||||||
"tag": "0024_inventory_export_jobs",
|
"tag": "0024_inventory_export_jobs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790755200000,
|
||||||
|
"tag": "0025_saved_views",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ Remaining roadmap areas are bulk status actions, asynchronous large exports, app
|
|||||||
- 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 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.
|
- 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.
|
- Seller-scoped admin settings are implemented through `GET/PATCH /api/v1/admin/settings` with strict category allowlists and server-side partial-update merging.
|
||||||
|
- Seller-scoped saved-view CRUD is implemented through the documented admin API with validated filter maps and repository-level ownership checks.
|
||||||
- Large inventory JSON exports can be enqueued through `POST /api/v1/admin/exports/inventory.json/jobs` and polled through `GET /api/v1/admin/exports/jobs/:id`; the worker owns claim, completion, and failure processing.
|
- Large inventory JSON exports can be enqueued through `POST /api/v1/admin/exports/inventory.json/jobs` and polled through `GET /api/v1/admin/exports/jobs/:id`; the worker owns claim, completion, and failure processing.
|
||||||
- Completed asynchronous inventory JSON exports can be downloaded through `GET /api/v1/admin/exports/jobs/:id/download`.
|
- Completed asynchronous inventory JSON exports can be downloaded through `GET /api/v1/admin/exports/jobs/:id/download`.
|
||||||
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
|
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"./product": "./product.js",
|
"./product": "./product.js",
|
||||||
"./pricing": "./pricing.js",
|
"./pricing": "./pricing.js",
|
||||||
"./settings": "./settings.js",
|
"./settings": "./settings.js",
|
||||||
|
"./saved-view": "./saved-view.js",
|
||||||
"./provider-mapping": "./provider-mapping.js"
|
"./provider-mapping": "./provider-mapping.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const filterValue = z.union([
|
||||||
|
z.string().trim().max(200),
|
||||||
|
z.number().finite(),
|
||||||
|
z.boolean(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const savedViewSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
filters: z.record(z.string().trim().min(1).max(100), filterValue).default({}),
|
||||||
|
}).strict();
|
||||||
Reference in New Issue
Block a user