diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 57018246..ce432829 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -598,6 +598,17 @@ "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.", "Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?", "Move to trash": "Move to trash", + "Make temporary": "Make temporary", + "Make permanent": "Make permanent", + "New temporary note": "New temporary note", + "Temporary note": "Temporary note", + "Temporary notes": "Temporary notes", + "Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent", + "Note will move to trash unless made permanent": "Note will move to trash unless made permanent", + "Note is now permanent": "Note is now permanent", + "Temporary note lifetime (hours)": "Temporary note lifetime (hours)", + "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.": "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.", + "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.", "Move this page to trash?": "Move this page to trash?", "Restore page": "Restore page", "Permanently delete": "Permanently delete", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 1ce29237..fc2e6942 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -607,6 +607,17 @@ "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.", "Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?", "Move to trash": "Переместить в корзину", + "Make temporary": "Сделать временной", + "Make permanent": "Сделать постоянной", + "New temporary note": "Новая временная заметка", + "Temporary note": "Временная заметка", + "Temporary notes": "Временные заметки", + "Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной", + "Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной", + "Note is now permanent": "Заметка теперь постоянная", + "Temporary note lifetime (hours)": "Время жизни временной заметки (часы)", + "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.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.", + "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.", "Move this page to trash?": "Переместить эту страницу в корзину?", "Restore page": "Восстановить страницу", "Permanently delete": "Удалить навсегда", diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index e9dcff4b..6cae0ee2 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -26,6 +26,7 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t import { PageEditMode } from "@/features/user/types/user.types.ts"; import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx"; import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx"; +import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx"; import clsx from "clsx"; import { currentPageEditModeAtom, @@ -37,6 +38,7 @@ const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); const MemoizedFixedToolbar = React.memo(FixedToolbar); const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner); +const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner); type PageUser = { id: string; @@ -103,6 +105,7 @@ export function FullEditor({ )} + ({ + mutationFn: (data) => toggleTemporary(data), + onError: (err: any) => { + notifications.show({ + message: + err?.response?.data?.message || "Failed to update temporary note", + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/page-embed/services/page-embed-api.ts b/apps/client/src/features/page-embed/services/page-embed-api.ts index be203c2c..c2e39cb0 100644 --- a/apps/client/src/features/page-embed/services/page-embed-api.ts +++ b/apps/client/src/features/page-embed/services/page-embed-api.ts @@ -2,6 +2,7 @@ import api from "@/lib/api-client"; import type { PageTemplateLookup, ToggleTemplateResponse, + ToggleTemporaryResponse, } from "../types/page-embed.types"; export async function lookupTemplate(params: { @@ -18,3 +19,11 @@ export async function toggleTemplate(params: { const r = await api.post("/pages/toggle-template", params); return r.data; } + +export async function toggleTemporary(params: { + pageId: string; + temporary?: boolean; +}): Promise { + const r = await api.post("/pages/toggle-temporary", params); + return r.data; +} diff --git a/apps/client/src/features/page-embed/types/page-embed.types.ts b/apps/client/src/features/page-embed/types/page-embed.types.ts index 63ed1a38..68694065 100644 --- a/apps/client/src/features/page-embed/types/page-embed.types.ts +++ b/apps/client/src/features/page-embed/types/page-embed.types.ts @@ -14,3 +14,9 @@ export type ToggleTemplateResponse = { pageId: string; isTemplate: boolean; }; + +export type ToggleTemporaryResponse = { + pageId: string; + // null => the note was made permanent; ISO string => armed deadline. + temporaryExpiresAt: string | null; +}; diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 4636d0b7..8de3983a 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant import { IconArrowRight, IconArrowsHorizontal, + IconClockHour4, IconDots, IconEye, IconEyeOff, @@ -24,6 +25,8 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { useToggleTemporaryMutation } from "@/features/page-embed/queries/page-embed-query.ts"; +import { queryClient } from "@/main.tsx"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; @@ -160,6 +163,41 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { const { data: watchStatus } = useWatchStatusQuery(page?.id); const watchPage = useWatchPageMutation(); const unwatchPage = useUnwatchPageMutation(); + const toggleTemporary = useToggleTemporaryMutation(); + const isTemporary = !!page?.temporaryExpiresAt; + + const handleToggleTemporary = async () => { + if (!page?.id) return; + const next = !isTemporary; + try { + const res = await toggleTemporary.mutateAsync({ + pageId: page.id, + temporary: next, + }); + // Reflect the new deadline in the page cache so the menu label flips and + // any banner updates. The sidebar icon refreshes via its own query. + for (const key of [page.slugId, page.id]) { + const cached = queryClient.getQueryData(["pages", key]); + if (cached) { + queryClient.setQueryData(["pages", key], { + ...cached, + temporaryExpiresAt: res.temporaryExpiresAt, + }); + } + } + queryClient.invalidateQueries({ + predicate: (item) => + ["sidebar-pages"].includes(item.queryKey[0] as string), + }); + notifications.show({ + message: next + ? t("Note will move to trash unless made permanent") + : t("Note is now permanent"), + }); + } catch { + // mutation surfaces the error via notifications + } + }; const handleCopyLink = () => { const pageUrl = @@ -309,6 +347,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { {!readOnly && ( <> + } + onClick={handleToggleTemporary} + > + {isTemporary ? t("Make permanent") : t("Make temporary")} + } diff --git a/apps/client/src/features/page/components/temporary-note-banner.tsx b/apps/client/src/features/page/components/temporary-note-banner.tsx new file mode 100644 index 00000000..2fd92a3b --- /dev/null +++ b/apps/client/src/features/page/components/temporary-note-banner.tsx @@ -0,0 +1,97 @@ +import { Button, Group, Paper, Text } from "@mantine/core"; +import { IconClockHour4 } from "@tabler/icons-react"; +import { Trans, useTranslation } from "react-i18next"; +import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { useToggleTemporaryMutation } from "@/features/page-embed/queries/page-embed-query.ts"; +import { queryClient } from "@/main.tsx"; +import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type.ts"; + +type TemporaryNoteBannerProps = { + slugId: string; +}; + +/** + * Banner shown on an open temporary note ("structure or die"). Mirrors + * DeletedPageBanner: it reads the page from the shared query cache and offers + * the explicit rescue action — "Make permanent". Children ride along to trash + * with the note, which is noted in the copy. + */ +export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) { + const { t } = useTranslation(); + const { data: page } = usePageQuery({ pageId: slugId }); + const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); + const spaceAbility = useSpaceAbility(space?.membership?.permissions); + const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt); + const toggleTemporary = useToggleTemporaryMutation(); + + // Don't show on a note that is already in trash; the deleted-page banner + // owns that state. + if (!page?.temporaryExpiresAt || page?.deletedAt) return null; + + const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page); + + const handleMakePermanent = async () => { + try { + const res = await toggleTemporary.mutateAsync({ + pageId: page.id, + temporary: false, + }); + for (const key of [page.slugId, page.id]) { + const cached = queryClient.getQueryData(["pages", key]); + if (cached) { + queryClient.setQueryData(["pages", key], { + ...cached, + temporaryExpiresAt: res.temporaryExpiresAt, + }); + } + } + queryClient.invalidateQueries({ + predicate: (item) => + ["sidebar-pages"].includes(item.queryKey[0] as string), + }); + } catch { + // mutation surfaces the error via notifications + } + }; + + return ( + + + + + + + + + {canEdit && ( + + )} + + + ); +} diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index e09fcbe3..cd868746 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconArrowRight, + IconClockHour4, IconCopy, IconDotsVertical, IconFileExport, @@ -30,7 +31,10 @@ import { useRemoveFavoriteMutation, } from "@/features/favorite/queries/favorite-query"; -import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query"; +import { + useToggleTemplateMutation, + useToggleTemporaryMutation, +} from "@/features/page-embed/queries/page-embed-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; @@ -65,6 +69,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { const isFavorited = favoriteIds.has(node.id); const toggleTemplate = useToggleTemplateMutation(); const isTemplate = !!node.isTemplate; + const toggleTemporary = useToggleTemporaryMutation(); + const isTemporary = !!node.temporaryExpiresAt; const handleToggleTemplate = async () => { const next = !isTemplate; @@ -84,6 +90,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { } }; + const handleToggleTemporary = async () => { + const next = !isTemporary; + try { + const res = await toggleTemporary.mutateAsync({ + pageId: node.id, + temporary: next, + }); + // Reflect the new deadline locally so the icon/menu update immediately. + setData((prev) => + treeModel.update(prev, node.id, { + temporaryExpiresAt: res.temporaryExpiresAt, + } as any), + ); + notifications.show({ + message: next + ? t("Note will move to trash unless made permanent") + : t("Note is now permanent"), + }); + } catch { + // mutation surfaces the error via notifications + } + }; + const handleCopyLink = () => { const pageUrl = getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name); @@ -248,6 +277,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { {isTemplate ? t("Unset as template") : t("Make template")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleToggleTemporary(); + }} + > + {isTemporary ? t("Make permanent") : t("Make temporary")} + + )} + {node.temporaryExpiresAt && ( + + + + )} +
diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index 2a3f97d1..6a0e82cb 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -22,7 +22,10 @@ import { getSpaceUrl } from "@/lib/config.ts"; export type UseTreeMutation = { handleMove: (sourceId: string, op: DropOp) => Promise; - handleCreate: (parentId: string | null) => Promise; + handleCreate: ( + parentId: string | null, + opts?: { temporary?: boolean }, + ) => Promise; handleRename: (id: string, name: string) => Promise; handleDelete: (id: string) => Promise; }; @@ -119,9 +122,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { ); const handleCreate = useCallback( - async (parentId: string | null) => { - const payload: { spaceId: string; parentPageId?: string } = { spaceId }; + async (parentId: string | null, opts?: { temporary?: boolean }) => { + const payload: { + spaceId: string; + parentPageId?: string; + temporary?: boolean; + } = { spaceId }; if (parentId) payload.parentPageId = parentId; + // Ask the server to arm the death timer for a "temporary note". + if (opts?.temporary) payload.temporary = true; let createdPage: IPage; try { @@ -138,6 +147,8 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { spaceId: createdPage.spaceId, parentPageId: createdPage.parentPageId, hasChildren: false, + // Show the temporary-note icon immediately on optimistic insert. + temporaryExpiresAt: createdPage.temporaryExpiresAt, children: [], }; diff --git a/apps/client/src/features/page/tree/types.ts b/apps/client/src/features/page/tree/types.ts index 66c04de1..673d94f8 100644 --- a/apps/client/src/features/page/tree/types.ts +++ b/apps/client/src/features/page/tree/types.ts @@ -9,5 +9,7 @@ export type SpaceTreeNode = { hasChildren: boolean; canEdit?: boolean; isTemplate?: boolean; + // Death-timer deadline. null/absent => permanent; ISO string => temporary note. + temporaryExpiresAt?: string | null; children: SpaceTreeNode[]; }; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 56f6ab02..8abb16e1 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] { parentPageId: page.parentPageId, canEdit: page.canEdit ?? page.permissions?.canEdit, isTemplate: page.isTemplate, + temporaryExpiresAt: page.temporaryExpiresAt, children: [], }; }); diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 6a8a0417..d06bc99b 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -13,6 +13,10 @@ export interface IPage { workspaceId: string; isLocked: boolean; isTemplate?: boolean; + // Death-timer deadline. null/absent => permanent; ISO string => temporary note. + temporaryExpiresAt?: string | null; + // Create-only input flag: ask the server to arm the timer on a new page. + temporary?: boolean; lastUpdatedById: string; createdAt: Date; updatedAt: Date; diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index b6ccfefc..51ebc6b1 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -13,6 +13,7 @@ import { IconEye, IconEyeOff, IconFileExport, + IconHourglass, IconPlus, IconSettings, IconStar, @@ -71,6 +72,10 @@ export function SpaceSidebar() { handleCreate(null); } + function handleCreateTemporaryPage() { + handleCreate(null, { temporary: true }); + } + return ( <>
@@ -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 354a80fb..aeb59eff 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'; // 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)) 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 8821ecfb..a3d14099 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -58,6 +58,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 d3135273..75fe01a6 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; @@ -419,6 +420,7 @@ export interface WorkspaceInvitations { export interface Workspaces { auditRetentionDays: Generated; trashRetentionDays: Generated; + temporaryNoteHours: Generated; billingEmail: string | null; createdAt: Generated; customDomain: string | null;