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:
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export class WorkspaceRepo {
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
'temporaryNoteHours',
|
||||
'isScimEnabled',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
2
apps/server/src/database/types/db.d.ts
vendored
2
apps/server/src/database/types/db.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user