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);
|
await noteAgentActionRoutes(app as never);
|
||||||
|
|
||||||
expect(app.get).toHaveBeenCalledTimes(1);
|
expect(app.get).toHaveBeenCalledTimes(1);
|
||||||
expect(app.post).toHaveBeenCalledTimes(1);
|
expect(app.post).toHaveBeenCalledTimes(2);
|
||||||
expect(app.patch).toHaveBeenCalledTimes(1);
|
expect(app.patch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { extractAuth } from '../../lib/auth.js';
|
|||||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import {
|
import {
|
||||||
|
BatchReviewSchema,
|
||||||
CreateNoteAgentActionSchema,
|
CreateNoteAgentActionSchema,
|
||||||
ListNoteAgentActionsQuerySchema,
|
ListNoteAgentActionsQuerySchema,
|
||||||
UpdateNoteAgentActionSchema,
|
UpdateNoteAgentActionSchema,
|
||||||
@ -77,11 +78,19 @@ export async function noteAgentActionRoutes(app: FastifyInstance) {
|
|||||||
throw new NotFoundError('Note agent action not found');
|
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,
|
...parsed.data,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: now,
|
||||||
updatedBy: auth.sub,
|
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) {
|
if (!updated) {
|
||||||
throw new NotFoundError('Note agent action not found');
|
throw new NotFoundError('Note agent action not found');
|
||||||
@ -89,4 +98,45 @@ export async function noteAgentActionRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
return updated;
|
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),
|
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 CreateNoteAgentActionInput = z.infer<typeof CreateNoteAgentActionSchema>;
|
||||||
export type UpdateNoteAgentActionInput = z.infer<typeof UpdateNoteAgentActionSchema>;
|
export type UpdateNoteAgentActionInput = z.infer<typeof UpdateNoteAgentActionSchema>;
|
||||||
export type ListNoteAgentActionsQuery = z.infer<typeof ListNoteAgentActionsQuerySchema>;
|
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/notes/routes.js', () => ({ noteRoutes: vi.fn() }));
|
||||||
vi.mock('./modules/note-relationships/routes.js', () => ({ noteRelationshipRoutes: 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/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('./modules/workspaces/routes.js', () => ({ workspaceRoutes: vi.fn() }));
|
||||||
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
|
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
|
||||||
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
|
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
|
||||||
@ -55,7 +56,7 @@ describe('server bootstrap', () => {
|
|||||||
expect(initDatastoreMock).toHaveBeenCalledOnce();
|
expect(initDatastoreMock).toHaveBeenCalledOnce();
|
||||||
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
||||||
expect(registerOptionalJwtContextMock).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' });
|
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 { noteRoutes } from './modules/notes/routes.js';
|
||||||
import { noteRelationshipRoutes } from './modules/note-relationships/routes.js';
|
import { noteRelationshipRoutes } from './modules/note-relationships/routes.js';
|
||||||
import { noteTaskRoutes } from './modules/note-tasks/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 { workspaceRoutes } from './modules/workspaces/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
@ -49,6 +50,7 @@ await registerApiPlugin(noteArtifactRoutes);
|
|||||||
await registerApiPlugin(noteRoutes);
|
await registerApiPlugin(noteRoutes);
|
||||||
await registerApiPlugin(noteRelationshipRoutes);
|
await registerApiPlugin(noteRelationshipRoutes);
|
||||||
await registerApiPlugin(noteTaskRoutes);
|
await registerApiPlugin(noteTaskRoutes);
|
||||||
|
await registerApiPlugin(savedViewRoutes);
|
||||||
await registerApiPlugin(workspaceRoutes);
|
await registerApiPlugin(workspaceRoutes);
|
||||||
|
|
||||||
await startService(app, { port: config.PORT, host: config.HOST });
|
await startService(app, { port: config.PORT, host: config.HOST });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user