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
This commit is contained in:
parent
839218a19c
commit
8d84bcb841
@ -15,3 +15,17 @@ const { extractAuth, requireRole } = createAuthMiddleware({
|
||||
});
|
||||
|
||||
export { extractAuth, requireRole };
|
||||
|
||||
/**
|
||||
* Convenience: require the caller to have at least 'editor' or 'admin' role.
|
||||
* Use on write routes (POST/PATCH/DELETE) that need role enforcement.
|
||||
*/
|
||||
export async function requireWriter(req: unknown) {
|
||||
const payload = await extractAuth(req);
|
||||
const role = (payload as { role?: string }).role;
|
||||
if (role !== 'editor' && role !== 'admin' && role !== 'owner') {
|
||||
const { ForbiddenError } = await import('@bytelyst/errors');
|
||||
throw new ForbiddenError('Write access requires editor, admin, or owner role');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listNoteAgentActions: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -40,6 +40,13 @@ export async function createNoteArtifact(doc: NoteArtifactDoc): Promise<NoteArti
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteNoteArtifact(id: string, workspaceId: string): Promise<boolean> {
|
||||
const existing = await collection().findById(id, workspaceId);
|
||||
if (!existing) return false;
|
||||
await collection().delete(id, workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateNoteArtifact(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
|
||||
@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listNoteArtifacts: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { extractAuth, requireWriter } from '../../lib/auth.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
@ -23,7 +23,7 @@ export async function noteArtifactRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/note-artifacts', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const parsed = CreateNoteArtifactSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
|
||||
@ -54,7 +54,7 @@ export async function noteArtifactRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.patch('/note-artifacts/:id', async (req: FastifyRequest) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
|
||||
|
||||
@ -84,4 +84,26 @@ export async function noteArtifactRoutes(app: FastifyInstance) {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/note-artifacts/:id', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
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.getNoteArtifact(id, workspaceId);
|
||||
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
|
||||
throw new NotFoundError('Note artifact not found');
|
||||
}
|
||||
|
||||
const deleted = await repo.deleteNoteArtifact(id, workspaceId);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Note artifact not found');
|
||||
}
|
||||
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -35,3 +35,14 @@ export async function listRelationships(
|
||||
export async function createRelationship(doc: NoteRelationshipDoc): Promise<NoteRelationshipDoc> {
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteRelationship(id: string, workspaceId: string): Promise<boolean> {
|
||||
const existing = await collection().findById(id, workspaceId);
|
||||
if (!existing) return false;
|
||||
await collection().delete(id, workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getRelationship(id: string, workspaceId: string): Promise<NoteRelationshipDoc | null> {
|
||||
return collection().findById(id, workspaceId);
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listRelationships: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError } from '@bytelyst/errors';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { extractAuth, requireWriter } from '../../lib/auth.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
@ -22,7 +22,7 @@ export async function noteRelationshipRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/note-relationships', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const parsed = CreateNoteRelationshipSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||
@ -47,4 +47,26 @@ export async function noteRelationshipRoutes(app: FastifyInstance) {
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.delete('/note-relationships/: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.getRelationship(id, workspaceId);
|
||||
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
|
||||
throw new NotFoundError('Relationship not found');
|
||||
}
|
||||
|
||||
const deleted = await repo.deleteRelationship(id, workspaceId);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Relationship not found');
|
||||
}
|
||||
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -40,6 +40,13 @@ export async function createNoteTask(doc: NoteTaskDoc): Promise<NoteTaskDoc> {
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteNoteTask(id: string, workspaceId: string): Promise<boolean> {
|
||||
const existing = await collection().findById(id, workspaceId);
|
||||
if (!existing) return false;
|
||||
await collection().delete(id, workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateNoteTask(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
|
||||
@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listNoteTasks: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FastifyApp } from '@bytelyst/fastify-core';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { extractAuth, requireWriter } from '../../lib/auth.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import * as repo from './repository.js';
|
||||
import {
|
||||
@ -25,7 +25,7 @@ export async function noteTaskRoutes(app: RouteApp) {
|
||||
});
|
||||
|
||||
app.post('/note-tasks', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const parsed = CreateNoteTaskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
|
||||
@ -55,7 +55,7 @@ export async function noteTaskRoutes(app: RouteApp) {
|
||||
});
|
||||
|
||||
app.patch('/note-tasks/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
|
||||
|
||||
@ -85,4 +85,26 @@ export async function noteTaskRoutes(app: RouteApp) {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/note-tasks/: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.getNoteTask(id, workspaceId);
|
||||
if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) {
|
||||
throw new NotFoundError('Note task not found');
|
||||
}
|
||||
|
||||
const deleted = await repo.deleteNoteTask(id, workspaceId);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Note task not found');
|
||||
}
|
||||
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,6 +81,21 @@ export async function createNote(doc: NoteDoc): Promise<NoteDoc> {
|
||||
return decryptFields(created);
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string, workspaceId: string): Promise<boolean> {
|
||||
const existing = await collection().findById(id, workspaceId);
|
||||
if (!existing) return false;
|
||||
|
||||
const merged = await encryptFields({
|
||||
...existing,
|
||||
status: 'archived' as const,
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as NoteDoc);
|
||||
|
||||
await collection().upsert(merged);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
|
||||
@ -2,14 +2,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('../../lib/extraction-client.js', () => ({
|
||||
extractFromText: vi.fn(async () => ({ summary: 'A concise summary.' })),
|
||||
}));
|
||||
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
import { noteRoutes } from './routes.js';
|
||||
|
||||
@ -15,7 +15,7 @@ const {
|
||||
updateNoteMock: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('../../lib/extraction-client.js', () => ({ extractFromText: vi.fn(async () => ({ summary: 'test' })) }));
|
||||
vi.mock('../note-artifacts/repository.js', () => ({ createNoteArtifact: vi.fn(async (doc: unknown) => doc) }));
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { FastifyApp } from '@bytelyst/fastify-core';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
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';
|
||||
@ -11,6 +13,9 @@ 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) {
|
||||
@ -55,7 +60,7 @@ export async function noteRoutes(app: RouteApp) {
|
||||
});
|
||||
|
||||
app.post('/notes', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const parsed = CreateNoteSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(
|
||||
@ -84,12 +89,13 @@ export async function noteRoutes(app: RouteApp) {
|
||||
};
|
||||
|
||||
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 extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
|
||||
|
||||
@ -119,11 +125,12 @@ export async function noteRoutes(app: RouteApp) {
|
||||
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 extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
||||
|
||||
@ -150,7 +157,7 @@ export async function noteRoutes(app: RouteApp) {
|
||||
});
|
||||
|
||||
app.post('/notes/:id/archive', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
||||
|
||||
@ -173,11 +180,12 @@ export async function noteRoutes(app: RouteApp) {
|
||||
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 extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId;
|
||||
|
||||
@ -243,4 +251,22 @@ export async function noteRoutes(app: RouteApp) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listSavedViews: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -30,6 +30,13 @@ export async function createWorkspace(doc: WorkspaceDoc): Promise<WorkspaceDoc>
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(id: string, userId: string): Promise<boolean> {
|
||||
const existing = await collection().findById(id, userId);
|
||||
if (!existing) return false;
|
||||
await collection().delete(id, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateWorkspace(
|
||||
id: string,
|
||||
userId: string,
|
||||
|
||||
@ -2,11 +2,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access' })),
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
import { workspaceRoutes } from './routes.js';
|
||||
|
||||
@ -5,7 +5,7 @@ const { extractAuthMock } = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock }));
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
||||
vi.mock('./repository.js', () => ({
|
||||
listWorkspaces: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { extractAuth, requireWriter } from '../../lib/auth.js';
|
||||
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||
import { trackEvent } from '../../lib/telemetry.js';
|
||||
import * as repo from './repository.js';
|
||||
import { countNotesByWorkspaces } from '../notes/repository.js';
|
||||
import {
|
||||
@ -51,7 +52,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/workspaces', async (req, reply) => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const parsed = CreateWorkspaceSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||
@ -72,12 +73,13 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
||||
};
|
||||
|
||||
const created = await repo.createWorkspace(doc);
|
||||
trackEvent({ event: 'workspace.created', userId: auth.sub, properties: { workspaceId: created.id } });
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.patch('/workspaces/:id', async req => {
|
||||
const auth = await extractAuth(req);
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = UpdateWorkspaceSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@ -101,4 +103,21 @@ export async function workspaceRoutes(app: FastifyInstance) {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/workspaces/:id', async (req, reply) => {
|
||||
const auth = await requireWriter(req);
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const existing = await repo.getWorkspace(id, auth.sub);
|
||||
if (!existing || existing.productId !== PRODUCT_ID) {
|
||||
throw new NotFoundError('Workspace not found');
|
||||
}
|
||||
|
||||
const deleted = await repo.deleteWorkspace(id, auth.sub);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Workspace not found');
|
||||
}
|
||||
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,31 +66,31 @@ cd ../learning_ai_notes/backend && pnpm install && pnpm run typecheck
|
||||
|
||||
These block the web app from being usable by real users.
|
||||
|
||||
- [ ] **1.1** Add auth pages — login, register, forgot-password
|
||||
- [x] **1.1** Add auth pages — login, register, forgot-password — [`839218a`](https://github.com/saravanakumardb1/learning_ai_notes/commit/839218a)
|
||||
- Create `web/src/app/(auth)/login/page.tsx`, `register/page.tsx`, `forgot-password/page.tsx`
|
||||
- Wire to existing `@bytelyst/react-auth` config in `web/src/lib/auth.ts`
|
||||
- Include form validation, error states, loading states
|
||||
- Files: new pages under `web/src/app/(auth)/`
|
||||
|
||||
- [ ] **1.2** Add `middleware.ts` for route protection
|
||||
- [x] **1.2** Add `middleware.ts` for route protection — [`839218a`](https://github.com/saravanakumardb1/learning_ai_notes/commit/839218a)
|
||||
- Redirect unauthenticated users from `(app)/*` routes to `/login`
|
||||
- Redirect authenticated users from `/login` to `/dashboard`
|
||||
- Check kill-switch status (call `checkKillSwitch()` from `web/src/lib/kill-switch.ts`)
|
||||
- File: `web/src/middleware.ts`
|
||||
|
||||
- [ ] **1.3** Replace plain textarea with a rich note editor
|
||||
- [x] **1.3** Replace plain textarea with a rich note editor — [`839218a`](https://github.com/saravanakumardb1/learning_ai_notes/commit/839218a)
|
||||
- Current `NoteEditor.tsx` is a bare `<textarea>` — inadequate for a notes product
|
||||
- Add markdown support (headings, bold, italic, lists, code blocks) at minimum
|
||||
- Consider Tiptap, Lexical, or MDXEditor
|
||||
- File: `web/src/components/NoteEditor.tsx`
|
||||
|
||||
- [ ] **1.4** Add workspace CRUD from web
|
||||
- [x] **1.4** Add workspace CRUD from web — [`839218a`](https://github.com/saravanakumardb1/learning_ai_notes/commit/839218a)
|
||||
- Backend already has `POST /workspaces` and `PATCH /workspaces/:id`
|
||||
- Add create-workspace modal/form on the workspaces page
|
||||
- Add edit/delete actions on workspace cards
|
||||
- Files: `web/src/app/(app)/workspaces/page.tsx`, new modal component
|
||||
|
||||
- [ ] **1.5** Wire toast notifications
|
||||
- [x] **1.5** Wire toast notifications — [`839218a`](https://github.com/saravanakumardb1/learning_ai_notes/commit/839218a)
|
||||
- `ToastProvider` from `@bytelyst/ui` is mounted but zero toasts are triggered
|
||||
- Replace `window.confirm()` calls with a confirmation dialog component
|
||||
- Add success/error toasts after note create, update, archive, link, task create, artifact create
|
||||
|
||||
Loading…
Reference in New Issue
Block a user