learning_ai_notes/backend/src/modules/notes/routes.ts
Saravana Achu Mac 8d84bcb841 feat: add DELETE endpoints, role enforcement, telemetry and feature flags
Phase 2 of the execution roadmap:
- Add DELETE endpoints for notes (soft-delete), workspaces, tasks, artifacts, relationships
- Add requireWriter() role enforcement on all write routes (POST/PATCH/DELETE)
- Activate trackEvent() telemetry on note.created, note.updated, note.archived, workspace.created
- Gate notes search behind isFeatureEnabled('notes.enabled')
- Update all test mocks to include role and new auth exports

Made-with: Cursor
2026-03-29 20:47:12 -07:00

273 lines
9.0 KiB
TypeScript

import type { FastifyApp } from '@bytelyst/fastify-core';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { extractAuth, requireWriter } from '../../lib/auth.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { trackEvent } from '../../lib/telemetry.js';
import { isFeatureEnabled } from '../../lib/feature-flags.js';
import { extractFromText } from '../../lib/extraction-client.js';
import * as repo from './repository.js';
import * as artifactRepo from '../note-artifacts/repository.js';
import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js';
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
export async function noteRoutes(app: RouteApp) {
app.get('/notes/search', async req => {
if (!isFeatureEnabled('notes.enabled')) {
throw new BadRequestError('Notes feature is currently disabled');
}
const auth = await extractAuth(req);
const parsed = ListNotesQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
}
const result = await repo.listNotes(auth.sub, PRODUCT_ID, parsed.data);
return {
query: parsed.data.search ?? null,
...result,
limit: parsed.data.limit,
offset: parsed.data.offset,
};
});
app.get('/notes', async req => {
const auth = await extractAuth(req);
const parsed = ListNotesQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
}
const result = await repo.listNotes(auth.sub, PRODUCT_ID, parsed.data);
return { ...result, limit: parsed.data.limit, offset: parsed.data.offset };
});
app.get('/notes/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const note = await repo.getNote(id, workspaceId);
if (!note || note.userId !== auth.sub || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
return note;
});
app.post('/notes', async (req, reply) => {
const auth = await requireWriter(req);
const parsed = CreateNoteSchema.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: NoteDoc = {
id: parsed.data.id,
productId: PRODUCT_ID,
workspaceId: parsed.data.workspaceId,
userId: auth.sub,
title: parsed.data.title,
body: parsed.data.body,
status: 'draft',
tags: parsed.data.tags,
links: parsed.data.links,
sourceType: parsed.data.sourceType,
sourceUri: parsed.data.sourceUri,
createdAt: now,
updatedAt: now,
createdBy: auth.sub,
updatedBy: auth.sub,
agentId: parsed.data.agentId,
};
const created = await repo.createNote(doc);
trackEvent({ event: 'note.created', userId: auth.sub, properties: { noteId: created.id, workspaceId: created.workspaceId } });
reply.code(201);
return created;
});
app.patch('/notes/:id', async req => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const parsed = UpdateNoteSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(
parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')
);
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const updated = await repo.updateNote(id, workspaceId, {
...parsed.data,
updatedAt: new Date().toISOString(),
updatedBy: auth.sub,
});
if (!updated) {
throw new NotFoundError('Note not found');
}
trackEvent({ event: 'note.updated', userId: auth.sub, properties: { noteId: id, workspaceId } });
return updated;
});
app.post('/notes/:id/restore', async req => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const updated = await repo.updateNote(id, workspaceId, {
status: 'active',
updatedAt: new Date().toISOString(),
updatedBy: auth.sub,
});
if (!updated) {
throw new NotFoundError('Note not found');
}
return updated;
});
app.post('/notes/:id/archive', async req => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const updated = await repo.updateNote(id, workspaceId, {
status: 'archived',
updatedAt: new Date().toISOString(),
updatedBy: auth.sub,
});
if (!updated) {
throw new NotFoundError('Note not found');
}
trackEvent({ event: 'note.archived', userId: auth.sub, properties: { noteId: id, workspaceId } });
return updated;
});
app.post('/notes/:id/summarize', async (req, reply) => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
let summaryText: string;
try {
const result = await extractFromText(existing.body, 'summarization');
summaryText = result.summary ?? 'No summary generated.';
} catch {
summaryText = `Auto-summary of: ${existing.title}. ${existing.body.slice(0, 200)}...`;
}
const now = new Date().toISOString();
const artifact = await artifactRepo.createNoteArtifact({
id: `summary-${id}-${Date.now()}`,
productId: PRODUCT_ID,
workspaceId,
userId: auth.sub,
noteId: id,
artifactType: 'summary',
title: `Summary of ${existing.title}`,
description: summaryText,
createdAt: now,
createdBy: auth.sub,
updatedAt: now,
updatedBy: auth.sub,
});
reply.code(201);
return artifact;
});
app.get('/notes/export', async (req, reply) => {
const auth = await extractAuth(req);
const query = req.query as { format?: string; workspaceId?: string };
const format = query.format ?? 'json';
if (format !== 'json' && format !== 'markdown') {
throw new BadRequestError('format must be json or markdown');
}
const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 };
const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery);
if (format === 'markdown') {
const md = result.items
.map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`)
.join('\n');
reply.header('Content-Type', 'text/markdown');
reply.header('Content-Disposition', 'attachment; filename="notes-export.md"');
return md;
}
reply.header('Content-Type', 'application/json');
reply.header('Content-Disposition', 'attachment; filename="notes-export.json"');
return { exportedAt: new Date().toISOString(), notes: result.items };
});
app.delete('/notes/:id', async (req, reply) => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
if (!workspaceId) {
throw new BadRequestError('workspaceId is required');
}
const existing = await repo.getNote(id, workspaceId);
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
await repo.deleteNote(id, workspaceId);
reply.code(204).send();
});
}