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:
Saravana Achu Mac 2026-03-29 20:47:12 -07:00
parent 839218a19c
commit 8d84bcb841
26 changed files with 219 additions and 44 deletions

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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