feat(backend): add batch review endpoint + saved-views module

- note-agent-actions: added POST /batch-review for bulk approve/reject (up to 50 items)
- note-agent-actions: PATCH now auto-sets reviewedBy/reviewedAt on approve/reject
- saved-views: new module with full CRUD (types, repository, routes)
  - Cosmos container: saved_views, partition: /userId
  - Supports scope filtering (workspace, search, review)
- Registered saved-views routes in server.ts (7 modules total)
- Updated route count tests

Verification: backend typecheck + 18/18 tests pass.
This commit is contained in:
saravanakumardb1 2026-03-10 19:33:33 -07:00
parent 878c644dd8
commit bdbf387f88
8 changed files with 300 additions and 5 deletions

View File

@ -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);
});
});

View File

@ -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<NoteAgentActionDoc> = {
...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,
};
});
}

View File

@ -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<typeof CreateNoteAgentActionSchema>;
export type UpdateNoteAgentActionInput = z.infer<typeof UpdateNoteAgentActionSchema>;
export type ListNoteAgentActionsQuery = z.infer<typeof ListNoteAgentActionsQuerySchema>;
export type BatchReviewInput = z.infer<typeof BatchReviewSchema>;

View File

@ -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<SavedViewDoc>('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<SavedViewDoc | null> {
return collection().findById(id, userId);
}
export async function createSavedView(doc: SavedViewDoc): Promise<SavedViewDoc> {
return collection().create(doc);
}
export async function updateSavedView(
id: string,
userId: string,
updates: Partial<SavedViewDoc>
): Promise<SavedViewDoc | null> {
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<boolean> {
const existing = await collection().findById(id, userId);
if (!existing) {
return false;
}
await collection().delete(id, userId);
return true;
}

View File

@ -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;
});
}

View File

@ -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<string, string>;
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<typeof CreateSavedViewSchema>;
export type UpdateSavedViewInput = z.infer<typeof UpdateSavedViewSchema>;
export type ListSavedViewsQuery = z.infer<typeof ListSavedViewsQuerySchema>;

View File

@ -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' });
});
});

View File

@ -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 });