@@ -111,16 +116,39 @@ export function SpaceSidebar() {
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
-
-
+
-
-
-
+
+
+
+
+
+ {/* Standalone second button: a "temporary note" auto-moves to
+ trash after the workspace lifetime unless made permanent. */}
+
+
+
+
+
+ >
)}
diff --git a/apps/client/src/features/workspace/components/settings/components/temporary-note-settings.tsx b/apps/client/src/features/workspace/components/settings/components/temporary-note-settings.tsx
new file mode 100644
index 00000000..effa6d03
--- /dev/null
+++ b/apps/client/src/features/workspace/components/settings/components/temporary-note-settings.tsx
@@ -0,0 +1,86 @@
+import { useState } from "react";
+import { useAtom } from "jotai";
+import {
+ Button,
+ Group,
+ NumberInput,
+ Paper,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role.tsx";
+import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
+import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
+import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
+
+// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
+// has no explicit value configured yet.
+const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
+
+/**
+ * Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
+ * is frozen per-note at creation, so changing this only affects notes created
+ * afterwards. `temporaryNoteHours` is a top-level workspace column (like
+ * trashRetentionDays), not a nested setting.
+ */
+export default function TemporaryNoteSettings() {
+ const { t } = useTranslation();
+ const [workspace, setWorkspace] = useAtom(workspaceAtom);
+ const { isAdmin } = useUserRole();
+ const [isLoading, setIsLoading] = useState(false);
+ const [value, setValue] = useState
(
+ workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
+ );
+
+ async function handleSave() {
+ if (!value || value < 1) return;
+ setIsLoading(true);
+ try {
+ const updated = await updateWorkspace({
+ temporaryNoteHours: value,
+ } as Partial);
+ setWorkspace({ ...updated, temporaryNoteHours: value });
+ notifications.show({ message: t("Updated successfully") });
+ } catch (err) {
+ notifications.show({
+ message:
+ (err as any)?.response?.data?.message ?? t("Failed to update data"),
+ color: "red",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+ {t("Temporary notes")}
+
+
+
+
+ {t(
+ "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
+ )}
+
+ setValue(typeof v === "number" ? v : Number(v))}
+ disabled={!isAdmin || isLoading}
+ w={220}
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index 0dcdd5a3..18faf12e 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -28,6 +28,8 @@ export interface IWorkspace {
aiDictationStreaming?: boolean;
aiPublicShareAssistant?: boolean;
trashRetentionDays?: number;
+ // Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
+ temporaryNoteHours?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
isScimEnabled?: boolean;
diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
index 8db81681..efb410e5 100644
--- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx
+++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
+import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
+
>
);
}
diff --git a/apps/server/src/core/page/constants/temporary-note.constants.ts b/apps/server/src/core/page/constants/temporary-note.constants.ts
new file mode 100644
index 00000000..af2e95f2
--- /dev/null
+++ b/apps/server/src/core/page/constants/temporary-note.constants.ts
@@ -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;
diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts
index 5cf71e5a..81b15903 100644
--- a/apps/server/src/core/page/dto/create-page.dto.ts
+++ b/apps/server/src/core/page/dto/create-page.dto.ts
@@ -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;
}
diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts
index 56360941..50294dd5 100644
--- a/apps/server/src/core/page/page.module.ts
+++ b/apps/server/src/core/page/page.module.ts
@@ -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],
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index ff205350..0c531307 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -61,6 +61,7 @@ import {
AuthProvenanceData,
agentSourceFields,
} from '../../../common/decorators/auth-provenance.decorator';
+import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
@Injectable()
export class PageService {
@@ -123,6 +124,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;
@@ -155,6 +170,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,
@@ -339,6 +355,7 @@ export class PageService {
'spaceId',
'creatorId',
'isTemplate',
+ 'temporaryExpiresAt',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
diff --git a/apps/server/src/core/page/services/spec/temporary-note-cleanup.service.spec.ts b/apps/server/src/core/page/services/spec/temporary-note-cleanup.service.spec.ts
new file mode 100644
index 00000000..1cb8d9dc
--- /dev/null
+++ b/apps/server/src/core/page/services/spec/temporary-note-cleanup.service.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/server/src/core/page/services/temporary-note-cleanup.service.ts b/apps/server/src/core/page/services/temporary-note-cleanup.service.ts
new file mode 100644
index 00000000..0cd4b55d
--- /dev/null
+++ b/apps/server/src/core/page/services/temporary-note-cleanup.service.ts
@@ -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,
+ );
+ }
+ }
+}
diff --git a/apps/server/src/core/page/transclusion/dto/toggle-temporary.dto.ts b/apps/server/src/core/page/transclusion/dto/toggle-temporary.dto.ts
new file mode 100644
index 00000000..3b2cf016
--- /dev/null
+++ b/apps/server/src/core/page/transclusion/dto/toggle-temporary.dto.ts
@@ -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;
+}
diff --git a/apps/server/src/core/page/transclusion/page-template.controller.ts b/apps/server/src/core/page/transclusion/page-template.controller.ts
index db20ea42..13eafe10 100644
--- a/apps/server/src/core/page/transclusion/page-template.controller.ts
+++ b/apps/server/src/core/page/transclusion/page-template.controller.ts
@@ -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 };
+ }
}
diff --git a/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts b/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts
index df340b13..5b9c0f03 100644
--- a/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts
+++ b/apps/server/src/core/page/transclusion/spec/page-template.controller.spec.ts
@@ -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)
diff --git a/apps/server/src/core/page/transclusion/spec/page-temporary.controller.spec.ts b/apps/server/src/core/page/transclusion/spec/page-temporary.controller.spec.ts
new file mode 100644
index 00000000..082f82f0
--- /dev/null
+++ b/apps/server/src/core/page/transclusion/spec/page-temporary.controller.spec.ts
@@ -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');
+ });
+});
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 8d206b86..9f0fa3b8 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -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;
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 504ce33d..f73ec351 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -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) {
diff --git a/apps/server/src/database/migrations/20260626T130000-page-temporary-notes.ts b/apps/server/src/database/migrations/20260626T130000-page-temporary-notes.ts
new file mode 100644
index 00000000..101ffd54
--- /dev/null
+++ b/apps/server/src/database/migrations/20260626T130000-page-temporary-notes.ts
@@ -0,0 +1,40 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ // "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): Promise {
+ 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();
+}
diff --git a/apps/server/src/database/migrations/spec/20260626T130000-page-temporary-notes.spec.ts b/apps/server/src/database/migrations/spec/20260626T130000-page-temporary-notes.spec.ts
new file mode 100644
index 00000000..9be98e0b
--- /dev/null
+++ b/apps/server/src/database/migrations/spec/20260626T130000-page-temporary-notes.spec.ts
@@ -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');
+ });
+});
diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts
index 51c6132b..45cb57ab 100644
--- a/apps/server/src/database/repos/page/page.repo.ts
+++ b/apps/server/src/database/repos/page/page.repo.ts
@@ -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();
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index 60e0a66e..87503743 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -57,6 +57,7 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
+ 'temporaryNoteHours',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 169d8e60..bca4c4ec 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -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;
@@ -409,6 +410,7 @@ export interface WorkspaceInvitations {
export interface Workspaces {
auditRetentionDays: Generated;
trashRetentionDays: Generated;
+ temporaryNoteHours: Generated;
billingEmail: string | null;
createdAt: Generated;
customDomain: string | null;