From ffd239d27c8607ee9e39f520eb13fec07d12bcbe Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 10 Sep 2026 01:36:15 -0400 Subject: [PATCH] feat: add seller saved views --- apps/api/src/app/build-app.js | 5 ++ apps/api/src/app/server.js | 6 ++ .../api/src/infrastructure/database/schema.js | 14 +++ .../repositories/saved-view-repository.js | 32 +++++++ .../search/routes/admin-saved-view-routes.js | 44 ++++++++++ .../search/services/saved-view-service.js | 27 ++++++ .../application/saved-view-service.test.js | 22 +++++ .../http/admin-saved-view-routes.test.js | 48 +++++++++++ .../src/pages/AdminSavedViewsPage.stories.js | 5 ++ apps/web/src/pages/AdminSavedViewsPage.vue | 86 +++++++++++++++++++ apps/web/src/pages/AdminShell.vue | 1 + apps/web/src/router/index.js | 2 + .../src/services/admin-saved-view-service.js | 20 +++++ apps/web/tests/catalog-service.test.js | 20 +++++ database/migrations/0025_saved_views.sql | 11 +++ database/migrations/meta/_journal.json | 7 ++ docs/IMPLEMENTATION-STATUS.md | 1 + packages/validation/package.json | 1 + packages/validation/saved-view.js | 12 +++ 19 files changed, 364 insertions(+) create mode 100644 apps/api/src/modules/search/repositories/saved-view-repository.js create mode 100644 apps/api/src/modules/search/routes/admin-saved-view-routes.js create mode 100644 apps/api/src/modules/search/services/saved-view-service.js create mode 100644 apps/api/tests/application/saved-view-service.test.js create mode 100644 apps/api/tests/http/admin-saved-view-routes.test.js create mode 100644 apps/web/src/pages/AdminSavedViewsPage.stories.js create mode 100644 apps/web/src/pages/AdminSavedViewsPage.vue create mode 100644 apps/web/src/services/admin-saved-view-service.js create mode 100644 database/migrations/0025_saved_views.sql create mode 100644 packages/validation/saved-view.js 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 @@ + + +