diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js
index af1548f..1eade44 100644
--- a/apps/api/src/app/build-app.js
+++ b/apps/api/src/app/build-app.js
@@ -22,6 +22,7 @@ import { registerAdminPricingRoutes } from "../modules/pricing/routes/admin-pric
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";
+import { registerAdminSavedViewRoutes } from "../modules/search/routes/admin-saved-view-routes.js";
export function buildApp(dependencies) {
const app = Fastify();
@@ -98,6 +99,10 @@ export function buildApp(dependencies) {
...dependencies,
prefix: "/api/v1/admin",
});
+ app.register(registerAdminSavedViewRoutes, {
+ ...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 be1780b..64148ae 100644
--- a/apps/api/src/app/server.js
+++ b/apps/api/src/app/server.js
@@ -56,6 +56,8 @@ import { createAdminExportService } from "../modules/exports/services/admin-expo
import { createInventoryExportJobRepository } from "../modules/exports/repositories/inventory-export-job-repository.js";
import { createSellerSettingsRepository } from "../modules/settings/repositories/seller-settings-repository.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) {
const port = Number.parseInt(value ?? "3000", 10);
@@ -96,6 +98,9 @@ async function startServer() {
const sellerSettingsService = createSellerSettingsService({
sellerSettingsRepository: createSellerSettingsRepository(database.db),
});
+ const savedViewService = createSavedViewService({
+ savedViewRepository: createSavedViewRepository(database.db),
+ });
const providers = [];
if (process.env.PRICING_FIXTURE_PROVIDER_ENABLED === "true")
providers.push(createFixturePricingProvider());
@@ -171,6 +176,7 @@ async function startServer() {
inventoryItemService,
}),
sellerSettingsService,
+ savedViewService,
catalogLookupRepository,
publicImageService,
publicInventoryService: createPublicInventoryService({
diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js
index d496ce5..01d6262 100644
--- a/apps/api/src/infrastructure/database/schema.js
+++ b/apps/api/src/infrastructure/database/schema.js
@@ -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(
"sessions",
{
diff --git a/apps/api/src/modules/search/repositories/saved-view-repository.js b/apps/api/src/modules/search/repositories/saved-view-repository.js
new file mode 100644
index 0000000..ec1e491
--- /dev/null
+++ b/apps/api/src/modules/search/repositories/saved-view-repository.js
@@ -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;
+ },
+ };
+}
diff --git a/apps/api/src/modules/search/routes/admin-saved-view-routes.js b/apps/api/src/modules/search/routes/admin-saved-view-routes.js
new file mode 100644
index 0000000..e4c1119
--- /dev/null
+++ b/apps/api/src/modules/search/routes/admin-saved-view-routes.js
@@ -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();
+ });
+}
diff --git a/apps/api/src/modules/search/services/saved-view-service.js b/apps/api/src/modules/search/services/saved-view-service.js
new file mode 100644
index 0000000..d9357a6
--- /dev/null
+++ b/apps/api/src/modules/search/services/saved-view-service.js
@@ -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;
+ },
+ };
+}
diff --git a/apps/api/tests/application/saved-view-service.test.js b/apps/api/tests/application/saved-view-service.test.js
new file mode 100644
index 0000000..d989853
--- /dev/null
+++ b/apps/api/tests/application/saved-view-service.test.js
@@ -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 } } } }));
+});
diff --git a/apps/api/tests/http/admin-saved-view-routes.test.js b/apps/api/tests/http/admin-saved-view-routes.test.js
new file mode 100644
index 0000000..2a7e2a3
--- /dev/null
+++ b/apps/api/tests/http/admin-saved-view-routes.test.js
@@ -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);
+});
diff --git a/apps/web/src/pages/AdminSavedViewsPage.stories.js b/apps/web/src/pages/AdminSavedViewsPage.stories.js
new file mode 100644
index 0000000..93929f9
--- /dev/null
+++ b/apps/web/src/pages/AdminSavedViewsPage.stories.js
@@ -0,0 +1,5 @@
+import AdminSavedViewsPage from './AdminSavedViewsPage.vue';
+
+export default { title: 'Pages/AdminSavedViewsPage', component: AdminSavedViewsPage };
+
+export const SavedViews = {};
diff --git a/apps/web/src/pages/AdminSavedViewsPage.vue b/apps/web/src/pages/AdminSavedViewsPage.vue
new file mode 100644
index 0000000..2b3f480
--- /dev/null
+++ b/apps/web/src/pages/AdminSavedViewsPage.vue
@@ -0,0 +1,86 @@
+
+
+
+ Inventory management Loading saved views... {{ message }} {{ error }} No saved views yet.Saved views
+ Your saved views
+
+
+