Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions backend/notebook-permissions/local-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're listing permissions on this particular notebook, I feel like we don't have to do the delete check. That's metadata to worry about when choosing whether or not to show it in the notebook dashboard.


Expand Down Expand Up @@ -192,7 +194,9 @@ export class LocalPermissionsProvider implements PermissionsProvider {
async isOwner(userId: string, notebookId: string): Promise<boolean> {
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"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for this one. Someone can still own a deleted notebook.

.bind(notebookId, userId)
.first();

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

Expand All @@ -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 }>();
Expand Down Expand Up @@ -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 }>();
Expand All @@ -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})`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think the deleted_at check goes outside of the permissions provider.

)
.bind(userId, ...resourceIds)
.all<{ notebook_id: string }>();
Expand Down Expand Up @@ -344,31 +353,31 @@ 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 ?
`;
bindings = [userId, limit, offset];
} 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 ?
`;
bindings = [userId, limit, offset];
} 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 ?
`;
Expand Down
26 changes: 14 additions & 12 deletions backend/trpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotebookRow>();
Expand Down Expand Up @@ -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)
Expand All @@ -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<NotebookRow>();
Expand Down Expand Up @@ -348,7 +348,7 @@ export const appRouter = router({
`
UPDATE notebooks
SET ${updates.join(", ")}
WHERE id = ?
WHERE id = ? AND deleted_at IS NULL
`
)
.bind(...bindings)
Expand All @@ -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<NotebookRow>();
Expand All @@ -378,7 +378,7 @@ export const appRouter = router({
}
}),

// Delete notebook
// Delete notebook (soft delete)
deleteNotebook: authedProcedure
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could probably call this softDeleteNotebook now, so that someday when we bring in real delete that has a way to go.

.input(z.object({ nbId: z.string() }))
.mutation(async (opts) => {
Expand All @@ -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",
});
}

Expand Down Expand Up @@ -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 }>();
Expand Down
1 change: 1 addition & 0 deletions backend/trpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions migrations/0006_add_soft_delete_to_notebooks.sql
Original file line number Diff line number Diff line change
@@ -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);