diff --git a/backend/src/modules/note-agent-actions/routes.test.ts b/backend/src/modules/note-agent-actions/routes.test.ts index e75c33a..8f1f937 100644 --- a/backend/src/modules/note-agent-actions/routes.test.ts +++ b/backend/src/modules/note-agent-actions/routes.test.ts @@ -29,7 +29,7 @@ describe('noteAgentActionRoutes', () => { await noteAgentActionRoutes(app as never); expect(app.get).toHaveBeenCalledTimes(1); - expect(app.post).toHaveBeenCalledTimes(1); + expect(app.post).toHaveBeenCalledTimes(2); expect(app.patch).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/modules/note-agent-actions/routes.ts b/backend/src/modules/note-agent-actions/routes.ts index e590791..16c88f6 100644 --- a/backend/src/modules/note-agent-actions/routes.ts +++ b/backend/src/modules/note-agent-actions/routes.ts @@ -4,6 +4,7 @@ import { extractAuth } from '../../lib/auth.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; import * as repo from './repository.js'; import { + BatchReviewSchema, CreateNoteAgentActionSchema, ListNoteAgentActionsQuerySchema, UpdateNoteAgentActionSchema, @@ -77,11 +78,19 @@ export async function noteAgentActionRoutes(app: FastifyInstance) { throw new NotFoundError('Note agent action not found'); } - const updated = await repo.updateNoteAgentAction(id, workspaceId, { + const now = new Date().toISOString(); + const updates: Partial = { ...parsed.data, - updatedAt: new Date().toISOString(), + updatedAt: now, updatedBy: auth.sub, - }); + }; + + if (parsed.data.state === 'approved' || parsed.data.state === 'rejected') { + updates.reviewedBy = auth.sub; + updates.reviewedAt = parsed.data.reviewedAt ?? now; + } + + const updated = await repo.updateNoteAgentAction(id, workspaceId, updates); if (!updated) { throw new NotFoundError('Note agent action not found'); @@ -89,4 +98,45 @@ export async function noteAgentActionRoutes(app: FastifyInstance) { return updated; }); + + app.post('/note-agent-actions/batch-review', async (req: FastifyRequest) => { + const auth = await extractAuth(req); + const parsed = BatchReviewSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const now = new Date().toISOString(); + const results: Array<{ id: string; status: 'updated' | 'not_found' | 'error'; error?: string }> = []; + + for (const item of parsed.data.ids) { + try { + const existing = await repo.getNoteAgentAction(item.id, item.workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + results.push({ id: item.id, status: 'not_found' }); + continue; + } + + const updated = await repo.updateNoteAgentAction(item.id, item.workspaceId, { + state: parsed.data.state, + reviewedBy: auth.sub, + reviewedAt: now, + reviewNote: parsed.data.reviewNote, + updatedAt: now, + updatedBy: auth.sub, + }); + + results.push({ id: item.id, status: updated ? 'updated' : 'not_found' }); + } catch (err) { + results.push({ id: item.id, status: 'error', error: err instanceof Error ? err.message : 'Unknown error' }); + } + } + + return { + state: parsed.data.state, + total: results.length, + updated: results.filter((r) => r.status === 'updated').length, + results, + }; + }); } diff --git a/backend/src/modules/note-agent-actions/types.ts b/backend/src/modules/note-agent-actions/types.ts index 134a2c6..33ab913 100644 --- a/backend/src/modules/note-agent-actions/types.ts +++ b/backend/src/modules/note-agent-actions/types.ts @@ -72,6 +72,16 @@ export const ListNoteAgentActionsQuerySchema = z.object({ offset: z.coerce.number().int().min(0).default(0), }); +export const BatchReviewSchema = z.object({ + ids: z.array(z.object({ + id: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + })).min(1).max(50), + state: z.enum(['approved', 'rejected']), + reviewNote: z.string().max(4000).optional(), +}); + export type CreateNoteAgentActionInput = z.infer; export type UpdateNoteAgentActionInput = z.infer; export type ListNoteAgentActionsQuery = z.infer; +export type BatchReviewInput = z.infer; diff --git a/backend/src/modules/saved-views/repository.ts b/backend/src/modules/saved-views/repository.ts new file mode 100644 index 0000000..0a50df3 --- /dev/null +++ b/backend/src/modules/saved-views/repository.ts @@ -0,0 +1,72 @@ +import { getCollection } from '../../lib/datastore.js'; +import type { SavedViewDoc, ListSavedViewsQuery } from './types.js'; +import type { FilterMap } from '@bytelyst/datastore'; + +function collection() { + return getCollection('saved_views', '/userId'); +} + +export async function listSavedViews( + userId: string, + productId: string, + query: ListSavedViewsQuery +): Promise<{ items: SavedViewDoc[]; total: number }> { + const filter: FilterMap = { userId, productId }; + + if (query.scope) filter.scope = query.scope; + + const total = await collection().count(filter); + const items = await collection().findMany({ + filter, + sort: { sortOrder: 1 }, + offset: query.offset, + limit: query.limit, + }); + + return { items, total }; +} + +export async function getSavedView( + id: string, + userId: string +): Promise { + return collection().findById(id, userId); +} + +export async function createSavedView(doc: SavedViewDoc): Promise { + return collection().create(doc); +} + +export async function updateSavedView( + id: string, + userId: string, + updates: Partial +): Promise { + const existing = await collection().findById(id, userId); + if (!existing) { + return null; + } + + const merged: SavedViewDoc = { + ...existing, + ...updates, + id: existing.id, + userId: existing.userId, + productId: existing.productId, + }; + + return collection().upsert(merged); +} + +export async function deleteSavedView( + id: string, + userId: string +): Promise { + const existing = await collection().findById(id, userId); + if (!existing) { + return false; + } + + await collection().delete(id, userId); + return true; +} diff --git a/backend/src/modules/saved-views/routes.ts b/backend/src/modules/saved-views/routes.ts new file mode 100644 index 0000000..78cdbaf --- /dev/null +++ b/backend/src/modules/saved-views/routes.ts @@ -0,0 +1,110 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import * as repo from './repository.js'; +import { + CreateSavedViewSchema, + ListSavedViewsQuerySchema, + UpdateSavedViewSchema, + type SavedViewDoc, +} from './types.js'; + +export async function savedViewRoutes(app: FastifyInstance) { + app.get('/saved-views', async (req: FastifyRequest) => { + const auth = await extractAuth(req); + const parsed = ListSavedViewsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const result = await repo.listSavedViews(auth.sub, PRODUCT_ID, parsed.data); + return { ...result, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + app.post('/saved-views', async (req: FastifyRequest, reply: FastifyReply) => { + const auth = await extractAuth(req); + const parsed = CreateSavedViewSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const now = new Date().toISOString(); + const doc: SavedViewDoc = { + id: parsed.data.id, + productId: PRODUCT_ID, + userId: auth.sub, + name: parsed.data.name, + scope: parsed.data.scope, + description: parsed.data.description, + query: parsed.data.query, + filters: parsed.data.filters, + sortOrder: parsed.data.sortOrder, + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }; + + const created = await repo.createSavedView(doc); + reply.code(201); + return created; + }); + + app.get('/saved-views/:id', async (req: FastifyRequest) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const view = await repo.getSavedView(id, auth.sub); + if (!view || view.productId !== PRODUCT_ID) { + throw new NotFoundError('Saved view not found'); + } + + return view; + }); + + app.patch('/saved-views/:id', async (req: FastifyRequest) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const parsed = UpdateSavedViewSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const existing = await repo.getSavedView(id, auth.sub); + if (!existing || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Saved view not found'); + } + + const updated = await repo.updateSavedView(id, auth.sub, { + ...parsed.data, + updatedAt: new Date().toISOString(), + updatedBy: auth.sub, + }); + + if (!updated) { + throw new NotFoundError('Saved view not found'); + } + + return updated; + }); + + app.delete('/saved-views/:id', async (req: FastifyRequest, reply: FastifyReply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const existing = await repo.getSavedView(id, auth.sub); + if (!existing || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Saved view not found'); + } + + const deleted = await repo.deleteSavedView(id, auth.sub); + if (!deleted) { + throw new NotFoundError('Saved view not found'); + } + + reply.code(204); + return; + }); +} diff --git a/backend/src/modules/saved-views/types.ts b/backend/src/modules/saved-views/types.ts new file mode 100644 index 0000000..ec4d7d9 --- /dev/null +++ b/backend/src/modules/saved-views/types.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const SAVED_VIEW_SCOPES = ['workspace', 'search', 'review'] as const; +export type SavedViewScope = (typeof SAVED_VIEW_SCOPES)[number]; + +export interface SavedViewDoc { + id: string; + productId: string; + userId: string; + name: string; + scope: SavedViewScope; + description?: string; + query: string; + filters?: Record; + sortOrder: number; + createdAt: string; + updatedAt: string; + createdBy: string; + updatedBy: string; + _ts?: number; + _etag?: string; +} + +export const CreateSavedViewSchema = z.object({ + id: z.string().min(1).max(128), + name: z.string().min(1).max(200), + scope: z.enum(SAVED_VIEW_SCOPES), + description: z.string().max(1000).optional(), + query: z.string().max(2000), + filters: z.record(z.string().max(500)).optional(), + sortOrder: z.number().int().min(0).max(999).default(0), +}); + +export const UpdateSavedViewSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional(), + query: z.string().max(2000).optional(), + filters: z.record(z.string().max(500)).optional(), + sortOrder: z.number().int().min(0).max(999).optional(), +}); + +export const ListSavedViewsQuerySchema = z.object({ + scope: z.enum(SAVED_VIEW_SCOPES).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type CreateSavedViewInput = z.infer; +export type UpdateSavedViewInput = z.infer; +export type ListSavedViewsQuery = z.infer; diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index e05e4a4..e9a5756 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -25,6 +25,7 @@ vi.mock('./modules/note-artifacts/routes.js', () => ({ noteArtifactRoutes: vi.fn vi.mock('./modules/notes/routes.js', () => ({ noteRoutes: vi.fn() })); vi.mock('./modules/note-relationships/routes.js', () => ({ noteRelationshipRoutes: vi.fn() })); vi.mock('./modules/note-tasks/routes.js', () => ({ noteTaskRoutes: vi.fn() })); +vi.mock('./modules/saved-views/routes.js', () => ({ savedViewRoutes: vi.fn() })); vi.mock('./modules/workspaces/routes.js', () => ({ workspaceRoutes: vi.fn() })); vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock })); vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock })); @@ -55,7 +56,7 @@ describe('server bootstrap', () => { expect(initDatastoreMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(6); + expect(appMock.register).toHaveBeenCalledTimes(7); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' }); }); }); diff --git a/backend/src/server.ts b/backend/src/server.ts index 3fa96a5..a58223b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,6 +5,7 @@ import { noteArtifactRoutes } from './modules/note-artifacts/routes.js'; import { noteRoutes } from './modules/notes/routes.js'; import { noteRelationshipRoutes } from './modules/note-relationships/routes.js'; import { noteTaskRoutes } from './modules/note-tasks/routes.js'; +import { savedViewRoutes } from './modules/saved-views/routes.js'; import { workspaceRoutes } from './modules/workspaces/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { initDatastore } from './lib/datastore.js'; @@ -49,6 +50,7 @@ await registerApiPlugin(noteArtifactRoutes); await registerApiPlugin(noteRoutes); await registerApiPlugin(noteRelationshipRoutes); await registerApiPlugin(noteTaskRoutes); +await registerApiPlugin(savedViewRoutes); await registerApiPlugin(workspaceRoutes); await startService(app, { port: config.PORT, host: config.HOST });