diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 516849a..c6b5acd 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -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; +} diff --git a/backend/src/modules/note-agent-actions/routes.integration.test.ts b/backend/src/modules/note-agent-actions/routes.integration.test.ts index c7b7525..258426c 100644 --- a/backend/src/modules/note-agent-actions/routes.integration.test.ts +++ b/backend/src/modules/note-agent-actions/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/note-agent-actions/routes.test.ts b/backend/src/modules/note-agent-actions/routes.test.ts index fcbd547..ca1dd3e 100644 --- a/backend/src/modules/note-agent-actions/routes.test.ts +++ b/backend/src/modules/note-agent-actions/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/note-artifacts/repository.ts b/backend/src/modules/note-artifacts/repository.ts index 6312f5c..43ea787 100644 --- a/backend/src/modules/note-artifacts/repository.ts +++ b/backend/src/modules/note-artifacts/repository.ts @@ -40,6 +40,13 @@ export async function createNoteArtifact(doc: NoteArtifactDoc): Promise { + 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, diff --git a/backend/src/modules/note-artifacts/routes.integration.test.ts b/backend/src/modules/note-artifacts/routes.integration.test.ts index 59822af..6613512 100644 --- a/backend/src/modules/note-artifacts/routes.integration.test.ts +++ b/backend/src/modules/note-artifacts/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/note-artifacts/routes.test.ts b/backend/src/modules/note-artifacts/routes.test.ts index 8482561..5c7e6c0 100644 --- a/backend/src/modules/note-artifacts/routes.test.ts +++ b/backend/src/modules/note-artifacts/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/note-artifacts/routes.ts b/backend/src/modules/note-artifacts/routes.ts index d78f025..48ebf2f 100644 --- a/backend/src/modules/note-artifacts/routes.ts +++ b/backend/src/modules/note-artifacts/routes.ts @@ -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(); + }); } diff --git a/backend/src/modules/note-relationships/repository.ts b/backend/src/modules/note-relationships/repository.ts index 8d02be8..e4e552c 100644 --- a/backend/src/modules/note-relationships/repository.ts +++ b/backend/src/modules/note-relationships/repository.ts @@ -35,3 +35,14 @@ export async function listRelationships( export async function createRelationship(doc: NoteRelationshipDoc): Promise { return collection().create(doc); } + +export async function deleteRelationship(id: string, workspaceId: string): Promise { + 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 { + return collection().findById(id, workspaceId); +} diff --git a/backend/src/modules/note-relationships/routes.integration.test.ts b/backend/src/modules/note-relationships/routes.integration.test.ts index f650a1e..51cb861 100644 --- a/backend/src/modules/note-relationships/routes.integration.test.ts +++ b/backend/src/modules/note-relationships/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/note-relationships/routes.test.ts b/backend/src/modules/note-relationships/routes.test.ts index 21cdb57..ad62ac9 100644 --- a/backend/src/modules/note-relationships/routes.test.ts +++ b/backend/src/modules/note-relationships/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/note-relationships/routes.ts b/backend/src/modules/note-relationships/routes.ts index 0024a08..c5e7196 100644 --- a/backend/src/modules/note-relationships/routes.ts +++ b/backend/src/modules/note-relationships/routes.ts @@ -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(); + }); } diff --git a/backend/src/modules/note-tasks/repository.ts b/backend/src/modules/note-tasks/repository.ts index 7d1bea0..afb71de 100644 --- a/backend/src/modules/note-tasks/repository.ts +++ b/backend/src/modules/note-tasks/repository.ts @@ -40,6 +40,13 @@ export async function createNoteTask(doc: NoteTaskDoc): Promise { return collection().create(doc); } +export async function deleteNoteTask(id: string, workspaceId: string): Promise { + 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, diff --git a/backend/src/modules/note-tasks/routes.integration.test.ts b/backend/src/modules/note-tasks/routes.integration.test.ts index 9123b29..dc8f6b0 100644 --- a/backend/src/modules/note-tasks/routes.integration.test.ts +++ b/backend/src/modules/note-tasks/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/note-tasks/routes.test.ts b/backend/src/modules/note-tasks/routes.test.ts index 6f8ab9c..bd7251d 100644 --- a/backend/src/modules/note-tasks/routes.test.ts +++ b/backend/src/modules/note-tasks/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/note-tasks/routes.ts b/backend/src/modules/note-tasks/routes.ts index b7e1e57..e50f9d0 100644 --- a/backend/src/modules/note-tasks/routes.ts +++ b/backend/src/modules/note-tasks/routes.ts @@ -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(); + }); } diff --git a/backend/src/modules/notes/repository.ts b/backend/src/modules/notes/repository.ts index 5ef4739..e2d14a3 100644 --- a/backend/src/modules/notes/repository.ts +++ b/backend/src/modules/notes/repository.ts @@ -81,6 +81,21 @@ export async function createNote(doc: NoteDoc): Promise { return decryptFields(created); } +export async function deleteNote(id: string, workspaceId: string): Promise { + 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, diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index 7ad74df..19779bb 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/notes/routes.test.ts b/backend/src/modules/notes/routes.test.ts index 46fe00f..dab4826 100644 --- a/backend/src/modules/notes/routes.test.ts +++ b/backend/src/modules/notes/routes.test.ts @@ -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) })); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index e1e4c4e..055ee55 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -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; 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(); + }); } diff --git a/backend/src/modules/saved-views/routes.integration.test.ts b/backend/src/modules/saved-views/routes.integration.test.ts index 7180b46..6e00df4 100644 --- a/backend/src/modules/saved-views/routes.integration.test.ts +++ b/backend/src/modules/saved-views/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/saved-views/routes.test.ts b/backend/src/modules/saved-views/routes.test.ts index 0aac91a..74ebce5 100644 --- a/backend/src/modules/saved-views/routes.test.ts +++ b/backend/src/modules/saved-views/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/workspaces/repository.ts b/backend/src/modules/workspaces/repository.ts index 48e76ce..ab95164 100644 --- a/backend/src/modules/workspaces/repository.ts +++ b/backend/src/modules/workspaces/repository.ts @@ -30,6 +30,13 @@ export async function createWorkspace(doc: WorkspaceDoc): Promise return collection().create(doc); } +export async function deleteWorkspace(id: string, userId: string): Promise { + 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, diff --git a/backend/src/modules/workspaces/routes.integration.test.ts b/backend/src/modules/workspaces/routes.integration.test.ts index f2fe7bb..b3c8e58 100644 --- a/backend/src/modules/workspaces/routes.integration.test.ts +++ b/backend/src/modules/workspaces/routes.integration.test.ts @@ -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'; diff --git a/backend/src/modules/workspaces/routes.test.ts b/backend/src/modules/workspaces/routes.test.ts index c777266..154b69e 100644 --- a/backend/src/modules/workspaces/routes.test.ts +++ b/backend/src/modules/workspaces/routes.test.ts @@ -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 })), diff --git a/backend/src/modules/workspaces/routes.ts b/backend/src/modules/workspaces/routes.ts index caf9dc2..0fd5219 100644 --- a/backend/src/modules/workspaces/routes.ts +++ b/backend/src/modules/workspaces/routes.ts @@ -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(); + }); } diff --git a/docs/AGENT_TASK_ROADMAP.md b/docs/AGENT_TASK_ROADMAP.md index f0664b6..088cb63 100644 --- a/docs/AGENT_TASK_ROADMAP.md +++ b/docs/AGENT_TASK_ROADMAP.md @@ -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 `