diff --git a/backend/notebook-permissions/local-permissions.ts b/backend/notebook-permissions/local-permissions.ts index 84a81a7d..1ca39c4c 100644 --- a/backend/notebook-permissions/local-permissions.ts +++ b/backend/notebook-permissions/local-permissions.ts @@ -151,7 +151,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { // Get owner const owner = await this.db - .prepare("SELECT owner_id, created_at FROM notebooks WHERE id = ?") + .prepare( + "SELECT owner_id, created_at FROM notebooks WHERE id = ? AND deleted_at IS NULL" + ) .bind(notebookId) .first<{ owner_id: string; created_at: string }>(); @@ -192,7 +194,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { async isOwner(userId: string, notebookId: string): Promise { try { const result = await this.db - .prepare("SELECT 1 FROM notebooks WHERE id = ? AND owner_id = ?") + .prepare( + "SELECT 1 FROM notebooks WHERE id = ? AND owner_id = ? AND deleted_at IS NULL" + ) .bind(notebookId, userId) .first(); @@ -218,7 +222,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { // Always include owned notebooks (owner implies all permissions) if (!permissions || permissions.includes("owner")) { const ownedNotebooks = await this.db - .prepare("SELECT id FROM notebooks WHERE owner_id = ?") + .prepare( + "SELECT id FROM notebooks WHERE owner_id = ? AND deleted_at IS NULL" + ) .bind(userId) .all<{ id: string }>(); @@ -229,7 +235,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { if (!permissions || permissions.includes("writer")) { const sharedNotebooks = await this.db .prepare( - "SELECT notebook_id FROM notebook_permissions WHERE user_id = ? AND permission = 'writer'" + `SELECT np.notebook_id FROM notebook_permissions np + INNER JOIN notebooks n ON np.notebook_id = n.id + WHERE np.user_id = ? AND np.permission = 'writer' AND n.deleted_at IS NULL` ) .bind(userId) .all<{ notebook_id: string }>(); @@ -263,7 +271,7 @@ export class LocalPermissionsProvider implements PermissionsProvider { const placeholders = resourceIds.map(() => "?").join(","); const ownedNotebooks = await this.db .prepare( - `SELECT id FROM notebooks WHERE owner_id = ? AND id IN (${placeholders})` + `SELECT id FROM notebooks WHERE owner_id = ? AND deleted_at IS NULL AND id IN (${placeholders})` ) .bind(userId, ...resourceIds) .all<{ id: string }>(); @@ -273,8 +281,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { // Check notebooks with writer permissions const sharedNotebooks = await this.db .prepare( - `SELECT notebook_id FROM notebook_permissions - WHERE user_id = ? AND permission = 'writer' AND notebook_id IN (${placeholders})` + `SELECT np.notebook_id FROM notebook_permissions np + INNER JOIN notebooks n ON np.notebook_id = n.id + WHERE np.user_id = ? AND np.permission = 'writer' AND n.deleted_at IS NULL AND np.notebook_id IN (${placeholders})` ) .bind(userId, ...resourceIds) .all<{ notebook_id: string }>(); @@ -344,9 +353,9 @@ export class LocalPermissionsProvider implements PermissionsProvider { if (owned && !shared) { // Only owned notebooks query = ` - SELECT id, owner_id, title, created_at, updated_at + SELECT id, owner_id, title, created_at, updated_at, deleted_at FROM notebooks - WHERE owner_id = ? + WHERE owner_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT ? OFFSET ? `; @@ -354,10 +363,10 @@ export class LocalPermissionsProvider implements PermissionsProvider { } else if (shared && !owned) { // Only shared notebooks (writer permissions) query = ` - SELECT n.id, n.owner_id, n.title, n.created_at, n.updated_at + SELECT n.id, n.owner_id, n.title, n.created_at, n.updated_at, n.deleted_at FROM notebooks n INNER JOIN notebook_permissions np ON n.id = np.notebook_id - WHERE np.user_id = ? AND np.permission = 'writer' + WHERE np.user_id = ? AND np.permission = 'writer' AND n.deleted_at IS NULL ORDER BY n.updated_at DESC LIMIT ? OFFSET ? `; @@ -365,10 +374,10 @@ export class LocalPermissionsProvider implements PermissionsProvider { } else { // All accessible notebooks (owned + shared) query = ` - SELECT DISTINCT n.id, n.owner_id, n.title, n.created_at, n.updated_at + SELECT DISTINCT n.id, n.owner_id, n.title, n.created_at, n.updated_at, n.deleted_at FROM notebooks n LEFT JOIN notebook_permissions np ON n.id = np.notebook_id - WHERE n.owner_id = ? OR (np.user_id = ? AND np.permission = 'writer') + WHERE (n.owner_id = ? OR (np.user_id = ? AND np.permission = 'writer')) AND n.deleted_at IS NULL ORDER BY n.updated_at DESC LIMIT ? OFFSET ? `; diff --git a/backend/trpc/index.ts b/backend/trpc/index.ts index 3f92bddf..959275b6 100644 --- a/backend/trpc/index.ts +++ b/backend/trpc/index.ts @@ -225,7 +225,7 @@ export const appRouter = router({ } const notebook = await DB.prepare( - "SELECT * FROM notebooks WHERE id = ?" + "SELECT * FROM notebooks WHERE id = ? AND deleted_at IS NULL" ) .bind(nbId) .first(); @@ -267,8 +267,8 @@ export const appRouter = router({ const result = await DB.prepare( ` - INSERT INTO notebooks (id, owner_id, title, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO notebooks (id, owner_id, title, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, NULL) ` ) .bind(nbId, user.id, input.title, now, now) @@ -282,7 +282,7 @@ export const appRouter = router({ } const notebook = await DB.prepare( - "SELECT * FROM notebooks WHERE id = ?" + "SELECT * FROM notebooks WHERE id = ? AND deleted_at IS NULL" ) .bind(nbId) .first(); @@ -348,7 +348,7 @@ export const appRouter = router({ ` UPDATE notebooks SET ${updates.join(", ")} - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL ` ) .bind(...bindings) @@ -363,7 +363,7 @@ export const appRouter = router({ // Return updated notebook const notebook = await DB.prepare( - "SELECT * FROM notebooks WHERE id = ?" + "SELECT * FROM notebooks WHERE id = ? AND deleted_at IS NULL" ) .bind(nbId) .first(); @@ -378,7 +378,7 @@ export const appRouter = router({ } }), - // Delete notebook + // Delete notebook (soft delete) deleteNotebook: authedProcedure .input(z.object({ nbId: z.string() })) .mutation(async (opts) => { @@ -400,15 +400,17 @@ export const appRouter = router({ }); } - // Delete notebook (CASCADE will handle permissions) - const result = await DB.prepare("DELETE FROM notebooks WHERE id = ?") - .bind(nbId) + // Soft delete notebook by setting deleted_at timestamp + const result = await DB.prepare( + "UPDATE notebooks SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL" + ) + .bind(new Date().toISOString(), new Date().toISOString(), nbId) .run(); if (result.meta.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", - message: "Notebook not found", + message: "Notebook not found or already deleted", }); } @@ -490,7 +492,7 @@ export const appRouter = router({ try { const notebook = await DB.prepare( - "SELECT owner_id FROM notebooks WHERE id = ?" + "SELECT owner_id FROM notebooks WHERE id = ? AND deleted_at IS NULL" ) .bind(nbId) .first<{ owner_id: string }>(); diff --git a/backend/trpc/types.ts b/backend/trpc/types.ts index c690134d..52956c78 100644 --- a/backend/trpc/types.ts +++ b/backend/trpc/types.ts @@ -4,6 +4,7 @@ export interface NotebookRow { title: string | null; created_at: string; updated_at: string; + deleted_at: string | null; collaborators?: Array<{ id: string; givenName: string; diff --git a/migrations/0006_add_soft_delete_to_notebooks.sql b/migrations/0006_add_soft_delete_to_notebooks.sql new file mode 100644 index 00000000..9a98aeff --- /dev/null +++ b/migrations/0006_add_soft_delete_to_notebooks.sql @@ -0,0 +1,10 @@ +-- Migration number: 0006 2025-08-28T21:06:37.003Z + +-- Add deleted_at column to notebooks table for soft delete functionality +ALTER TABLE notebooks ADD COLUMN deleted_at TEXT DEFAULT NULL; + +-- Create index for efficient filtering of non-deleted notebooks +CREATE INDEX idx_notebooks_deleted_at ON notebooks(deleted_at); + +-- Create composite index for efficient queries that filter by owner and deleted status +CREATE INDEX idx_notebooks_owner_deleted ON notebooks(owner_id, deleted_at);