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,5 @@
|
||||
// Default lifetime for a temporary note, in HOURS, used when the workspace has
|
||||
// no `temporaryNoteHours` configured (NULL). Mirrors the trash-cleanup
|
||||
// DEFAULT_RETENTION_DAYS fallback. After this many hours a temporary note is
|
||||
// auto-moved to trash unless it was made permanent first.
|
||||
export const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,4 +33,10 @@ export class CreatePageDto {
|
||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
|
||||
// When true, create the page as a temporary note: arm its death timer
|
||||
// (now + workspace temporaryNoteHours) at creation.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { TemporaryNoteCleanupService } from './services/temporary-note-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
@@ -16,6 +17,7 @@ import { LabelModule } from '../label/label.module';
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
TemporaryNoteCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
AuthProvenanceData,
|
||||
agentSourceFields,
|
||||
} from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
|
||||
// descendant traversals) may walk. Real page trees are only a handful of levels
|
||||
@@ -140,6 +141,20 @@ export class PageService {
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
|
||||
// Freeze the death timer here so later changes to the workspace setting
|
||||
// never reschedule existing temporary notes. NULL => permanent page.
|
||||
let temporaryExpiresAt: Date | undefined;
|
||||
if (createPageDto.temporary) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['temporaryNoteHours'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
const hours =
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
let content = undefined;
|
||||
let textContent = undefined;
|
||||
let ydoc = undefined;
|
||||
@@ -172,6 +187,7 @@ export class PageService {
|
||||
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
||||
// user request leaves the column default ('user').
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
temporaryExpiresAt,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
@@ -356,6 +372,7 @@ export class PageService {
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isTemplate',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { TemporaryNoteCleanupService } from '../temporary-note-cleanup.service';
|
||||
|
||||
/**
|
||||
* Chainable Kysely stub that records every `.where(...)` call so the test can
|
||||
* assert the sweep only selects armed, expired, not-yet-trashed notes. The
|
||||
* terminal `.execute()` resolves the configured expired rows.
|
||||
*/
|
||||
function makeDbStub(expiredRows: any[]) {
|
||||
const whereCalls: any[][] = [];
|
||||
const builder: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn((...args: any[]) => {
|
||||
whereCalls.push(args);
|
||||
return builder;
|
||||
}),
|
||||
execute: jest.fn().mockResolvedValue(expiredRows),
|
||||
};
|
||||
return { builder, whereCalls };
|
||||
}
|
||||
|
||||
describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
|
||||
it('selects only armed, expired, not-yet-trashed notes', async () => {
|
||||
const { builder, whereCalls } = makeDbStub([]);
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
// temporaryExpiresAt IS NOT NULL, temporaryExpiresAt < now, deletedAt IS NULL
|
||||
const cols = whereCalls.map((c) => c[0]);
|
||||
const ops = whereCalls.map((c) => c[1]);
|
||||
expect(cols).toEqual([
|
||||
'temporaryExpiresAt',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
]);
|
||||
expect(ops).toEqual(['is not', '<', 'is']);
|
||||
// last operand is the trash filter -> null
|
||||
expect(whereCalls[2][2]).toBeNull();
|
||||
});
|
||||
|
||||
it('soft-deletes each expired note via removePage, attributed to its creator', async () => {
|
||||
const expired = [
|
||||
{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' },
|
||||
{ id: 'p2', creatorId: 'u2', workspaceId: 'w1' },
|
||||
];
|
||||
const { builder } = makeDbStub(expired);
|
||||
const pageRepo = { removePage: jest.fn().mockResolvedValue(undefined) } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(1, 'p1', 'u1', 'w1');
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'p2', 'u2', 'w1');
|
||||
});
|
||||
|
||||
it('continues past a failing note (one bad removePage does not abort the sweep)', async () => {
|
||||
const expired = [
|
||||
{ id: 'bad', creatorId: 'u1', workspaceId: 'w1' },
|
||||
{ id: 'good', creatorId: 'u2', workspaceId: 'w1' },
|
||||
];
|
||||
const { builder } = makeDbStub(expired);
|
||||
const pageRepo = {
|
||||
removePage: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
} as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await expect(
|
||||
service.sweepExpiredTemporaryNotes(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(pageRepo.removePage).toHaveBeenCalledTimes(2);
|
||||
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'good', 'u2', 'w1');
|
||||
});
|
||||
|
||||
it('does nothing when no notes are expired', async () => {
|
||||
const { builder } = makeDbStub([]);
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
|
||||
/**
|
||||
* Background sweeper for temporary notes ("structure or die"). A note whose
|
||||
* frozen deadline (`pages.temporary_expires_at`) has passed is auto-moved to
|
||||
* trash via the exact same soft-delete path as a manual delete. Modelled on
|
||||
* TrashCleanupService; `@nestjs/schedule` is already enabled globally.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TemporaryNoteCleanupService {
|
||||
private readonly logger = new Logger(TemporaryNoteCleanupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly pageRepo: PageRepo,
|
||||
) {}
|
||||
|
||||
// Hourly granularity: lifetimes are configured in hours, so a sub-hour
|
||||
// overshoot past the deadline is acceptable.
|
||||
@Interval('temporary-note-cleanup', 60 * 60 * 1000)
|
||||
async sweepExpiredTemporaryNotes() {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const expired = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'creatorId', 'workspaceId'])
|
||||
.where('temporaryExpiresAt', 'is not', null)
|
||||
.where('temporaryExpiresAt', '<', now)
|
||||
.where('deletedAt', 'is', null) // not already in trash
|
||||
.execute();
|
||||
|
||||
let trashed = 0;
|
||||
for (const page of expired) {
|
||||
try {
|
||||
// Reuse the exact soft-delete path: recursive over children, removes
|
||||
// shares in a transaction, and emits PAGE_SOFT_DELETED (tree
|
||||
// invalidation + watcher notifications). Attribute the automatic
|
||||
// deletion to the note's creator (no schema change). Both the SELECT
|
||||
// above and removePage filter `deletedAt IS NULL`, so a double sweep
|
||||
// is idempotent.
|
||||
await this.pageRepo.removePage(
|
||||
page.id,
|
||||
// creatorId is set on every created page; a temporary note always
|
||||
// has one. Cast to satisfy the non-null deletedById parameter.
|
||||
page.creatorId as string,
|
||||
page.workspaceId,
|
||||
);
|
||||
trashed++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to trash expired temporary note ${page.id}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (trashed > 0) {
|
||||
this.logger.debug(
|
||||
`Temporary-note cleanup completed: ${trashed} notes trashed`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Temporary-note cleanup job failed',
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class ToggleTemporaryDto {
|
||||
@IsUUID()
|
||||
pageId!: string;
|
||||
|
||||
/**
|
||||
* When omitted, the temporary state is toggled relative to its current value.
|
||||
* true -> arm the timer (now + workspace temporaryNoteHours);
|
||||
* false -> clear it (make permanent — "structure and survive").
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean;
|
||||
}
|
||||
@@ -16,8 +16,12 @@ import { TemplateLookupDto } from './dto/template-lookup.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
import { ToggleTemplateDto } from './dto/toggle-template.dto';
|
||||
import { ToggleTemporaryDto } from './dto/toggle-temporary.dto';
|
||||
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
|
||||
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -26,6 +30,7 @@ export class PageTemplateController {
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,4 +87,54 @@ export class PageTemplateController {
|
||||
|
||||
return { pageId: page.id, isTemplate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm or disarm the "death timer" on a page (`pages.temporary_expires_at`).
|
||||
* Mirror of toggle-template: requires Edit on the page/space (CASL enforced in
|
||||
* `validateCanEdit`). Arming freezes the deadline at now + the workspace's
|
||||
* temporaryNoteHours; disarming ("Make permanent") clears it. Same workspace
|
||||
* defense-in-depth as toggle-template (NotFound, never Forbidden, on mismatch).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('toggle-temporary')
|
||||
async toggleTemporary(
|
||||
@Body() dto: ToggleTemporaryDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
if (page.workspaceId !== user.workspaceId) {
|
||||
// Defense-in-depth: never act on a page outside the caller's workspace.
|
||||
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
const makeTemporary =
|
||||
typeof dto.temporary === 'boolean'
|
||||
? dto.temporary
|
||||
: page.temporaryExpiresAt == null;
|
||||
|
||||
let temporaryExpiresAt: Date | null = null;
|
||||
if (makeTemporary) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['temporaryNoteHours'])
|
||||
.where('id', '=', user.workspaceId)
|
||||
.executeTakeFirst();
|
||||
const hours =
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id);
|
||||
|
||||
return { pageId: page.id, temporaryExpiresAt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||
|
||||
describe('PageTemplateController.toggleTemplate', () => {
|
||||
let controller: PageTemplateController;
|
||||
@@ -40,6 +41,8 @@ describe('PageTemplateController.toggleTemplate', () => {
|
||||
{ provide: TransclusionService, useValue: transclusionService },
|
||||
{ provide: PageRepo, useValue: pageRepo },
|
||||
{ provide: PageAccessService, useValue: pageAccessService },
|
||||
// toggleTemporary reads the workspace lifetime; toggleTemplate ignores it.
|
||||
{ provide: KYSELY_MODULE_CONNECTION_TOKEN(), useValue: {} },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||
import { PageTemplateController } from '../page-template.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { ToggleTemporaryDto } from '../dto/toggle-temporary.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../../constants/temporary-note.constants';
|
||||
|
||||
/**
|
||||
* Minimal chainable Kysely stub: every builder method returns `this`, and the
|
||||
* terminal `executeTakeFirst` resolves the configured workspace row.
|
||||
*/
|
||||
function makeDbStub(workspaceRow: { temporaryNoteHours: number | null } | undefined) {
|
||||
const builder: any = {
|
||||
selectFrom: () => builder,
|
||||
select: () => builder,
|
||||
where: () => builder,
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
describe('PageTemplateController.toggleTemporary', () => {
|
||||
let controller: PageTemplateController;
|
||||
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
|
||||
let pageAccessService: { validateCanEdit: jest.Mock };
|
||||
|
||||
const user = { id: 'u1', workspaceId: 'w1' } as any;
|
||||
|
||||
async function buildController(
|
||||
page: any,
|
||||
workspaceRow: { temporaryNoteHours: number | null } | undefined = {
|
||||
temporaryNoteHours: null,
|
||||
},
|
||||
) {
|
||||
pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(page),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageAccessService = {
|
||||
validateCanEdit: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [PageTemplateController],
|
||||
providers: [
|
||||
{ provide: TransclusionService, useValue: { lookupTemplate: jest.fn() } },
|
||||
{ provide: PageRepo, useValue: pageRepo },
|
||||
{ provide: PageAccessService, useValue: pageAccessService },
|
||||
{
|
||||
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
|
||||
useValue: makeDbStub(workspaceRow),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(UserThrottlerGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get(PageTemplateController);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('throws NotFound and does not touch the page when missing', async () => {
|
||||
await buildController(null);
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFound (not Forbidden) for a cross-workspace page', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'OTHER',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces CASL edit: when validateCanEdit throws, the timer is NOT changed', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException());
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('arms the timer (toggle) using the default hours when the page is permanent', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
|
||||
const expected = new Date(
|
||||
Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: expected },
|
||||
'p1',
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: expected });
|
||||
});
|
||||
|
||||
it('uses the workspace temporaryNoteHours override when set', async () => {
|
||||
await buildController(
|
||||
{
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
},
|
||||
{ temporaryNoteHours: 3 },
|
||||
);
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
const expected = new Date(Date.now() + 3 * 60 * 60 * 1000);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: expected },
|
||||
'p1',
|
||||
);
|
||||
expect(out.temporaryExpiresAt).toEqual(expected);
|
||||
});
|
||||
|
||||
it('clears the timer (make permanent) when toggling an armed note', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: new Date('2026-06-27T00:00:00.000Z'),
|
||||
});
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: null },
|
||||
'p1',
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: null });
|
||||
});
|
||||
|
||||
it('respects an explicit temporary:false instead of toggling', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null, // already permanent, but explicit false
|
||||
});
|
||||
const out = await controller.toggleTemporary(
|
||||
{ pageId: 'p1', temporary: false } as any,
|
||||
user,
|
||||
);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: null },
|
||||
'p1',
|
||||
);
|
||||
expect(out.temporaryExpiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToggleTemporaryDto validation (class-validator)', () => {
|
||||
const uuid = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
it('accepts a valid UUID with no flag (toggle)', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, { pageId: uuid });
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts an explicit boolean temporary', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||
pageId: uuid,
|
||||
temporary: true,
|
||||
});
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a non-UUID pageId', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, { pageId: 'nope' });
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isUuid');
|
||||
});
|
||||
|
||||
it('rejects a non-boolean temporary', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||
pageId: uuid,
|
||||
temporary: 'yes',
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isBoolean');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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