feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)
"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").
Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
frozen deadline) + partial index for the sweep; workspace column
temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
notes through the exact PageRepo.removePage path (recursive over children,
emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
and en/ru i18n.
Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,13 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@Min(1)
|
||||
trashRetentionDays: number;
|
||||
|
||||
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
|
||||
// creation, so changing this never reschedules existing notes.
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
temporaryNoteHours: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowMemberTemplates: boolean;
|
||||
|
||||
@@ -330,6 +330,7 @@ export class WorkspaceService {
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||
@@ -337,7 +338,13 @@ export class WorkspaceService {
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
|
||||
.select([
|
||||
'id',
|
||||
'licenseKey',
|
||||
'plan',
|
||||
'trashRetentionDays',
|
||||
'temporaryNoteHours',
|
||||
])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -378,6 +385,14 @@ export class WorkspaceService {
|
||||
before.trashRetentionDays = ws.trashRetentionDays;
|
||||
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.temporaryNoteHours !== 'undefined' &&
|
||||
updateWorkspaceDto.temporaryNoteHours !== ws.temporaryNoteHours
|
||||
) {
|
||||
before.temporaryNoteHours = ws.temporaryNoteHours;
|
||||
after.temporaryNoteHours = updateWorkspaceDto.temporaryNoteHours;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
|
||||
Reference in New Issue
Block a user