feat: trash for deleted pages in space (#325)

* initial commit

* added recycle bin modal, updated api routes

* updated page service & controller, recycle bin modal

* updated page-query.ts, use-tree-mutation.ts, recycled-pages.ts

* removed quotes from openRestorePageModal prompt

* Updated page.repo.ts

* move button to space menu

* fix react issues

* opted to reload to enact changes in the client

* lint

* hide deleted pages in recents, handle restore child page

* fix null check

* WIP

* WIP

* feat: implement dedicated trash page
- Replace modal-based trash view with dedicated route `/s/:spaceSlug/trash`
- Add pagination support for deleted pages
- Other improvements

* fix translation

* trash cleanup cron

* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
Eddy Oyieko
2025-07-29 21:20:49 +01:00
committed by GitHub
co-authored by Philipinho
parent 28fcb11cb4
commit ec12e80423
22 changed files with 1062 additions and 200 deletions
@@ -17,8 +17,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { generateSlugId } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
@@ -37,6 +35,9 @@ import {
} from '../dto/duplicate-page.dto';
import { Node as PMNode } from '@tiptap/pm/model';
import { StorageService } from '../../../integrations/storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable()
export class PageService {
@@ -47,6 +48,7 @@ export class PageService {
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async findById(
@@ -169,23 +171,6 @@ export class PageService {
});
}
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
return eb
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'pages.id')
.limit(1)
.as('hasChildren');
}
async getSidebarPages(
spaceId: string,
pagination: PaginationOptions,
@@ -202,9 +187,11 @@ export class PageService {
'parentPageId',
'spaceId',
'creatorId',
'deletedAt',
])
.select((eb) => this.withHasChildren(eb))
.select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', 'asc')
.where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId);
if (pageId) {
@@ -527,9 +514,11 @@ export class PageService {
'position',
'parentPageId',
'spaceId',
'deletedAt',
])
.select((eb) => this.withHasChildren(eb))
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
@@ -541,6 +530,7 @@ export class PageService {
'p.position',
'p.parentPageId',
'p.spaceId',
'p.deletedAt',
])
.select(
exp
@@ -555,11 +545,13 @@ export class PageService {
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
@@ -583,98 +575,58 @@ export class PageService {
return await this.pageRepo.getRecentPages(userId, pagination);
}
async getDeletedSpacePages(
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
}
async forceDelete(pageId: string): Promise<void> {
await this.pageRepo.deletePage(pageId);
// Get all descendant IDs (including the page itself) using recursive CTE
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = descendants.map((d) => d.id);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
for (const id of pageIds) {
await this.attachmentQueue.add(
QueueJob.DELETE_PAGE_ATTACHMENTS,
{
pageId: id,
},
{
jobId: `delete-page-attachments-${id}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
);
}
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
}
}
async remove(pageId: string, userId: string): Promise<void> {
await this.pageRepo.removePage(pageId, userId);
}
}
/*
// TODO: page deletion and restoration
async delete(pageId: string): Promise<void> {
await this.dataSource.transaction(async (manager: EntityManager) => {
const page = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.select(['page.id', 'page.workspaceId'])
.getOne();
if (!page) {
throw new NotFoundException(`Page not found`);
}
await this.softDeleteChildrenRecursive(page.id, manager);
await this.pageOrderingService.removePageFromHierarchy(page, manager);
await manager.softDelete(Page, pageId);
});
}
private async softDeleteChildrenRecursive(
parentId: string,
manager: EntityManager,
): Promise<void> {
const childrenPage = await manager
.createQueryBuilder(Page, 'page')
.where('page.parentPageId = :parentId', { parentId })
.select(['page.id', 'page.title', 'page.parentPageId'])
.getMany();
for (const child of childrenPage) {
await this.softDeleteChildrenRecursive(child.id, manager);
await manager.softDelete(Page, child.id);
}
}
async restore(pageId: string): Promise<void> {
await this.dataSource.transaction(async (manager: EntityManager) => {
const isDeleted = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.withDeleted()
.getCount();
if (!isDeleted) {
return;
}
await manager.recover(Page, { id: pageId });
await this.restoreChildrenRecursive(pageId, manager);
// Fetch the page details to find out its parent and workspace
const restoredPage = await manager
.createQueryBuilder(Page, 'page')
.where('page.id = :pageId', { pageId })
.select(['page.id', 'page.title', 'page.spaceId', 'page.parentPageId'])
.getOne();
if (!restoredPage) {
throw new NotFoundException(`Restored page not found.`);
}
// add page back to its hierarchy
await this.pageOrderingService.addPageToOrder(
restoredPage.spaceId,
pageId,
restoredPage.parentPageId,
);
});
}
private async restoreChildrenRecursive(
parentId: string,
manager: EntityManager,
): Promise<void> {
const childrenPage = await manager
.createQueryBuilder(Page, 'page')
.setLock('pessimistic_write')
.where('page.parentPageId = :parentId', { parentId })
.select(['page.id', 'page.title', 'page.parentPageId'])
.withDeleted()
.getMany();
for (const child of childrenPage) {
await this.restoreChildrenRecursive(child.id, manager);
await manager.recover(Page, { id: child.id });
}
}
*/
@@ -0,0 +1,116 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
@Interval('trash-cleanup', 24 * 60 * 60 * 1000) // every 24 hours
async cleanupOldTrash() {
try {
this.logger.log('Starting trash cleanup job');
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
// Get all pages that were deleted more than 30 days ago
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id', 'spaceId', 'workspaceId'])
.where('deletedAt', '<', retentionDate)
.execute();
if (oldDeletedPages.length === 0) {
this.logger.debug('No old trash items to clean up');
return;
}
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
// Process each page
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
this.logger.debug('Trash cleanup job completed');
} catch (error) {
this.logger.error(
'Trash cleanup job failed',
error instanceof Error ? error.stack : undefined,
);
}
}
private async cleanupPage(pageId: string) {
// Get all descendants using recursive CTE (including the page itself)
const descendants = await this.db
.withRecursive('page_descendants', (db) =>
db
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
),
)
.selectFrom('page_descendants')
.selectAll()
.execute();
const pageIds = descendants.map((d) => d.id);
this.logger.debug(
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
);
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
for (const id of pageIds) {
await this.attachmentQueue.add(
QueueJob.DELETE_PAGE_ATTACHMENTS,
{
pageId: id,
},
{
jobId: `delete-page-attachments-${id}`,
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
);
}
try {
if (pageIds.length > 0) {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
}
} catch (error) {
// Log but don't throw - pages might have been deleted by another node
this.logger.warn(
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}