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:
claude code agent 227
2026-06-26 06:34:22 +03:00
parent fad1aa0501
commit eb5b696431
35 changed files with 1063 additions and 17 deletions

View File

@@ -0,0 +1,40 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// "Death timer" column. NULL = permanent page; non-NULL = temporary note,
// value is the exact moment the note auto-moves to trash. The deadline is
// frozen at creation, so changing the workspace setting never reschedules
// existing notes.
await db.schema
.alterTable('pages')
.addColumn('temporary_expires_at', 'timestamptz', (col) => col)
.execute();
// Partial index backing the cleanup sweep: only armed, not-yet-trashed notes.
await sql`
CREATE INDEX pages_temporary_expires_at_idx
ON pages (temporary_expires_at)
WHERE temporary_expires_at IS NOT NULL AND deleted_at IS NULL
`.execute(db);
// Default lifetime for new temporary notes, in HOURS. Frozen per-note at
// creation. NULL falls back to the in-code DEFAULT_TEMPORARY_NOTE_HOURS.
await db.schema
.alterTable('workspaces')
.addColumn('temporary_note_hours', 'int8', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropColumn('temporary_note_hours')
.execute();
await db.schema.dropIndex('pages_temporary_expires_at_idx').execute();
await db.schema
.alterTable('pages')
.dropColumn('temporary_expires_at')
.execute();
}

View File

@@ -0,0 +1,88 @@
// Mock the `sql` tagged template so the migration's partial-index statement is
// recorded without a real database. Keep `Kysely` (type-only) intact.
const sqlCalls: string[] = [];
jest.mock('kysely', () => ({
sql: (strings: TemplateStringsArray) => {
sqlCalls.push(strings.join('{}'));
return { execute: jest.fn().mockResolvedValue(undefined) };
},
}));
import {
up,
down,
} from '../20260626T130000-page-temporary-notes';
/**
* Chainable Kysely schema stub: each builder method returns `this` and records
* (method, firstArg) so the test can assert the columns/index the migration
* touches. `addColumn` runs its column-builder callback to exercise it.
*/
function makeSchemaStub() {
const calls: Array<[string, any]> = [];
const colBuilder: any = new Proxy(
{},
{ get: () => () => colBuilder },
);
const builder: any = {
schema: {} as any,
alterTable(name: string) {
calls.push(['alterTable', name]);
return builder;
},
addColumn(name: string, _type: any, cb?: (c: any) => any) {
calls.push(['addColumn', name]);
if (cb) cb(colBuilder);
return builder;
},
dropColumn(name: string) {
calls.push(['dropColumn', name]);
return builder;
},
dropIndex(name: string) {
calls.push(['dropIndex', name]);
return builder;
},
execute: jest.fn().mockResolvedValue(undefined),
};
builder.schema = builder;
return { db: builder, calls };
}
describe('migration 20260626T130000-page-temporary-notes', () => {
beforeEach(() => {
sqlCalls.length = 0;
});
it('up adds both columns and creates the partial cleanup index', async () => {
const { db, calls } = makeSchemaStub();
await up(db);
const added = calls.filter((c) => c[0] === 'addColumn').map((c) => c[1]);
expect(added).toContain('temporary_expires_at');
expect(added).toContain('temporary_note_hours');
const altered = calls.filter((c) => c[0] === 'alterTable').map((c) => c[1]);
expect(altered).toContain('pages');
expect(altered).toContain('workspaces');
// The partial index is created via the raw sql tag.
expect(sqlCalls.join(' ')).toContain('pages_temporary_expires_at_idx');
expect(sqlCalls.join(' ')).toContain('temporary_expires_at IS NOT NULL');
expect(sqlCalls.join(' ')).toContain('deleted_at IS NULL');
});
it('down reverses both columns and drops the index', async () => {
const { db, calls } = makeSchemaStub();
await down(db);
const dropped = calls.filter((c) => c[0] === 'dropColumn').map((c) => c[1]);
expect(dropped).toContain('temporary_expires_at');
expect(dropped).toContain('temporary_note_hours');
const droppedIdx = calls
.filter((c) => c[0] === 'dropIndex')
.map((c) => c[1]);
expect(droppedIdx).toContain('pages_temporary_expires_at_idx');
});
});

View File

@@ -51,6 +51,7 @@ export class PageRepo {
'workspaceId',
'isLocked',
'isTemplate',
'temporaryExpiresAt',
'createdAt',
'updatedAt',
'deletedAt',
@@ -425,7 +426,10 @@ export class PageRepo {
// Restore all pages, but only detach the root page if its parent is deleted
await this.db
.updateTable('pages')
.set({ deletedById: null, deletedAt: null })
// On restore, disarm the death timer: pulling a note out of trash means
// "keep it". Otherwise a deadline now in the past would re-trash it on the
// next cleanup sweep.
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null })
.where('id', 'in', pageIds)
.execute();

View File

@@ -58,6 +58,7 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
'temporaryNoteHours',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}

View File

@@ -297,6 +297,7 @@ export interface Pages {
position: string | null;
slugId: string;
spaceId: string;
temporaryExpiresAt: Timestamp | null;
textContent: string | null;
title: string | null;
tsv: string | null;
@@ -419,6 +420,7 @@ export interface WorkspaceInvitations {
export interface Workspaces {
auditRetentionDays: Generated<number>;
trashRetentionDays: Generated<number>;
temporaryNoteHours: Generated<number>;
billingEmail: string | null;
createdAt: Generated<Timestamp>;
customDomain: string | null;