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:
parent
878c644dd8
commit
bdbf387f88
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
72
backend/src/modules/saved-views/repository.ts
Normal file
72
backend/src/modules/saved-views/repository.ts
Normal 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;
|
||||
}
|
||||
110
backend/src/modules/saved-views/routes.ts
Normal file
110
backend/src/modules/saved-views/routes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
50
backend/src/modules/saved-views/types.ts
Normal file
50
backend/src/modules/saved-views/types.ts
Normal 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>;
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user