[feature] «Временные заметки»: автоперенос в корзину через X часов (структурируй или умри) #201

Closed
opened 2026-06-25 23:47:52 +03:00 by Ghost · 0 comments

Концепция

«Временная заметка» — заметка с «таймером смерти». Создаётся отдельной кнопкой; через настраиваемые X часов (по умолчанию 24) она автоматически уезжает в корзину тем же мягким удалением, что и при ручном «Удалить». Чтобы заметка выжила, нужно осознанное действие — «Сделать постоянной». Не сделал — умерла. Девиз: структурируй или умри.

Переиспользование существующих механизмов:

  • пометка/символ — как у шаблонов (флаг-колонка на pages → иконка в дереве → пункт меню-переключатель → toggle-эндпоинт), см. is_template;
  • автоудаление — как у очистки корзины: фоновый @Interval-сервис по образцу TrashCleanupService;
  • само удаление — существующий рекурсивный PageRepo.removePage (через корзину, не напрямую).

Жизненный цикл (две стадии смерти)

                  создать «временную»                  +X часов (фоновый сборщик)
   [ нет заметки ] ───────────────▶ [ ВРЕМЕННАЯ ]  ──────────────────────────▶ [ В КОРЗИНЕ ]
                                      expiresAt=t0+Xh    removePage() ставит        deletedAt=now
                                          │              deletedAt + emit
                                          │              PAGE_SOFT_DELETED
                          «Сделать       │                                            │
                           постоянной»   ▼                          trashRetentionDays │ (штатно)
                                    [ ОБЫЧНАЯ ]                                        ▼
                                  expiresAt = NULL                              [ УДАЛЕНА НАВСЕГДА ]
                                                                          (существующий TrashCleanupService)

Удаление двухстадийное: временная → (через X ч) корзина → (через trashRetentionDays) навсегда. Это даёт сетку безопасности — даже «умершую» заметку ещё можно восстановить из корзины.

Зафиксированные решения

  1. Отдельная вторая кнопка в шапке дерева пространства (standalone ActionIcon с иконкой песочных часов рядом с обычным «+»), а не split-меню.
  2. Дети уезжают в корзину вместе с временной заметкой (штатное поведение рекурсивного removePage; восстановление возвращает всё поддерево). Поведение задокументировать в подсказке.
  3. UI-редактор времени жизни нужен, значение задаётся в часах (не днях), на уровне workspace.

Модель данных

Решение: одна nullable-колонка-таймстемп вместо булева флага

Добавляем pages.temporary_expires_at timestamptz NULL:

  • NULL → заметка обычная;
  • не-NULL → заметка временная, значение = точный момент «смерти».

Признак «временная» = temporary_expires_at IS NOT NULL — он управляет и символом, и пунктом меню. Дедлайн замораживается при создании (now + X ч), поэтому изменение глобальной настройки X не «переигрывает» дедлайны уже созданных заметок.

Новая миграция (по образцу 20260620T130000-page-is-template.ts):

// "Death timer" column. NULL = permanent page; non-NULL = temporary note.
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);

Настройка X часов — на workspace (по образцу trash_retention_days, 20260228T223532-audit.ts):

// Default lifetime for new temporary notes, in HOURS. Frozen per-note at creation.
await db.schema
  .alterTable('workspaces')
  .addColumn('temporary_note_hours', 'int8', (col) => col)
  .execute();

Дефолт при NULL — константа в коде: DEFAULT_TEMPORARY_NOTE_HOURS = 24 (аналог DEFAULT_RETENTION_DAYS = 30).

Сопутствующие правки типов:

  • apps/server/src/database/types/db.d.ts: Pages.temporaryExpiresAt: Timestamp | null; Workspaces.temporaryNoteHours: Generated<number>.
  • apps/server/src/database/repos/page/page.repo.tsbaseFields: добавить 'temporaryExpiresAt'.
  • Клиент: IPage и SpaceTreeNodetemporaryExpiresAt?: string | null.

Серверная часть

1. Создание временной заметки

apps/server/src/core/page/dto/create-page.dto.ts:

@IsOptional()
@IsBoolean()
temporary?: boolean; // when true, create the page as a temporary note

page.service.ts create() (перед insertPage):

// Freeze the death timer here so later setting changes never reschedule existing notes.
let temporaryExpiresAt: Date | undefined;
if (createPageDto.temporary) {
  const hours = workspace.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
  temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
}
// ...insertPage({ ..., temporaryExpiresAt })

2. Переключатель «временная ⇄ постоянная» (toggle)

Зеркало механизма шаблонов. Новый эндпоинт POST /pages/toggle-temporary (рядом с toggle-template в page-template.controller.ts), DTO по образцу toggle-template.dto.ts:

export class ToggleTemporaryDto {
  @IsUUID() pageId!: string;

  // Omitted -> toggle relative to current state.
  // true  -> arm timer (now + workspace hours);  false -> clear (make permanent).
  @IsOptional() @IsBoolean() temporary?: boolean;
}

Логика (зеркало toggleTemplate): проверки существования / workspaceId / validateCanEdit, затем:

const makeTemporary =
  typeof dto.temporary === 'boolean' ? dto.temporary : page.temporaryExpiresAt == null;
const temporaryExpiresAt = makeTemporary
  ? new Date(Date.now() + (workspace.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS) * 3600_000)
  : null; // "Make permanent" — structure and survive
await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id);
return { pageId: page.id, temporaryExpiresAt };

Даёт сразу обе операции: «Сделать постоянной» (temporary:false) и симметричную «Сделать временной» для существующей заметки (как «Make template»).

3. Фоновый сборщик — TemporaryNoteCleanupService

Калька trash-cleanup.service.ts; регистрируется в page.module.ts рядом с TrashCleanupService. @nestjs/schedule уже глобально включён (TelemetryModule).

@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 in hours, so <1h overshoot is acceptable.
  @Interval('temporary-note-cleanup', 60 * 60 * 1000)
  async sweepExpiredTemporaryNotes() {
    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();

    for (const page of expired) {
      try {
        // Reuse the exact soft-delete path: recursive over children + emits PAGE_SOFT_DELETED.
        // Attribute the automatic deletion to the note's creator (no schema change).
        await this.pageRepo.removePage(page.id, page.creatorId, page.workspaceId);
      } catch (error) {
        this.logger.error(`Failed to trash expired temporary note ${page.id}`, error);
      }
    }
  }
}

Используем removePage, а не новый код: он уже рекурсивно проставляет deletedAt детям, удаляет shares в транзакции и эмитит PAGE_SOFT_DELETED (инвалидация дерева, watcher-уведомления) — page.repo.ts:296-378.

4. Восстановление из корзины

В restorePage (page.repo.ts:380-439) при восстановлении обнулять temporary_expires_at — иначе дедлайн в прошлом приведёт к мгновенному повторному удалению в ближайший час:

// On restore, disarm the death timer: pulling a note out of trash means "keep it".
.set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null })

5. Настройка X часов (workspace)

По образцу trashRetentionDays: поле в update-workspace.dto.ts (@IsOptional() @IsInt() @Min(1)), audit-tracking в workspace.service.ts update(), эндпоинт POST /workspace/update уже обрабатывает любые поля DTO. temporaryNoteHours добавить в baseFields workspace-репозитория.

Клиентская часть

1. Отдельная вторая кнопка создания

В шапке дерева apps/client/src/features/space/components/sidebar/space-sidebar.tsx:114-123 рядом с обычным IconPlus добавить вторую самостоятельную ActionIcon с IconHourglass (tooltip «Новая временная заметка»), вызывающую handleCreate(null, { temporary: true }).

Проброс флага в use-tree-mutation.ts:121-182:

const handleCreate = useCallback(
  async (parentId: string | null, opts?: { temporary?: boolean }) => {
    const payload: { spaceId: string; parentPageId?: string; temporary?: boolean } = { spaceId };
    if (parentId) payload.parentPageId = parentId;
    if (opts?.temporary) payload.temporary = true; // ask the server to arm the timer
    const createdPage = await createPageMutation.mutateAsync(payload);
    const newNode: SpaceTreeNode = {
      /* ...as before... */
      temporaryExpiresAt: createdPage.temporaryExpiresAt, // show the symbol immediately
    };
    // ...
  },
  [/* deps */],
);

create-page.dto / IPageInput на клиенте — добавить опциональный temporary?: boolean.

2. Символ-пометка в дереве

В space-tree-row.tsx:175-192 рядом с индикатором шаблона добавить аналог для временной заметки (@tabler/icons-react v3.40 доступен; напр. IconClockHour4 / IconHourglass, оранжевый цвет):

{node.temporaryExpiresAt && (
  <Tooltip label={t("Temporary · expires {{when}}", { when: formatRelative(node.temporaryExpiresAt) })} withArrow>
    <IconClockHour4
      size={14} stroke={1.5}
      style={{ flexShrink: 0, marginLeft: rem(4), color: "var(--mantine-color-orange-6)" }}
      aria-label={t("Temporary note")} role="img"
    />
  </Tooltip>
)}

Проброс поля с сервера в дерево: добавить temporaryExpiresAt в SidebarPageRow / shapeSidebarPagesTree (sidebar-pages-tree.util.ts) и в SELECT запроса sidebar-страниц; на клиенте — в маппинг buildTree (apps/client/src/features/page/tree/utils/utils.ts), как isTemplate.

3. Пункт меню «Сделать постоянной»

Зеркало пункта «Make template» в space-tree-node-menu.tsx:240-249 (хук useToggleTemporaryMutation по образцу useToggleTemplateMutation):

{canEdit && (
  <Menu.Item
    leftSection={<IconClockHour4 size={16} />}
    onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleToggleTemporary(); }}
  >
    {node.temporaryExpiresAt ? t("Make permanent") : t("Make temporary")}
  </Menu.Item>
)}

Тот же пункт продублировать в меню открытой страницы page-header-menu.tsx (PageActionMenu, рядом с «Move to trash»).

4. Баннер на открытой временной заметке

По образцу trash-banner.tsx: если у открытой страницы есть temporaryExpiresAt, показывать сверху предупреждение « Эта заметка переедет в корзину через N ч. Сделать постоянной?» с кнопкой действия. Усиливает философию «структурируй или умри» и даёт второй явный путь спасения.

5. UI-редактор времени жизни (в часах)

Новый компонент в настройках workspace apps/client/src/pages/settings/workspace/ (раздел General или новый «Retention»): NumberInput «Время жизни временной заметки (часы)», min=1, целое, сохранение через POST /workspace/update (temporaryNoteHours). Поле temporaryNoteHours добавить в клиентский IWorkspace.

Примечание: у существующей настройки trashRetentionDays UI-редактора сейчас нет — здесь делаем полноценный редактор, как просили.

6. i18n

Строки в apps/client/public/locales/en-US/translation.json и ru-RU/translation.json (плоский «ключ → значение»): Temporary note, Make temporary, Make permanent, New temporary note, Temporary · expires {{when}}, текст баннера, подпись настройки времени жизни.

Краевые случаи и решения

Случай Решение
Дочерние страницы у временной заметки Уезжают в корзину вместе с родителем (рекурсивный removePage). Восстановление возвращает поддерево. Решение зафиксировано (см. выше). Задокументировать в подсказке.
Дедлайн в прошлом после восстановления На restorePage обнуляем temporary_expires_at → заметка становится постоянной.
Кто «удалил» при автоудалении Передаём creatorId в removePage — без изменения схемы; в корзине видно «удалил <автор>».
Смена глобальной настройки X Не влияет на существующие заметки (дедлайн заморожен при создании), только на новые.
Гонка / повторное срабатывание Запрос фильтрует deletedAt IS NULL, removePage тоже фильтрует deletedAt IS NULL в WHERE → идемпотентно.
Точность срабатывания Сборщик раз в час → заметка может прожить до ~1 ч сверх дедлайна. Приемлемо для часовой гранулярности.
Права Toggle защищён validateCanEdit (как у шаблонов); создание — существующими проверками create.

Карта точек интеграции

Слой Файл Что добавить
Миграция новый файл в apps/server/src/database/migrations/ колонки pages.temporary_expires_at, workspaces.temporary_note_hours + partial index
Типы БД apps/server/src/database/types/db.d.ts поля в Pages и Workspaces
Repo apps/server/src/database/repos/page/page.repo.ts temporaryExpiresAt в baseFields; обнуление в restorePage
Create DTO apps/server/src/core/page/dto/create-page.dto.ts temporary?: boolean
Create service apps/server/src/core/page/services/page.service.ts расчёт temporaryExpiresAt
Toggle apps/server/src/core/page/transclusion/page-template.controller.ts + новый ToggleTemporaryDto POST /pages/toggle-temporary
Сборщик новый temporary-note-cleanup.service.ts + apps/server/src/core/page/page.module.ts @Interval-сервис
Sidebar shape apps/server/src/core/page/services/sidebar-pages-tree.util.ts проброс temporaryExpiresAt
Workspace настройка apps/server/src/core/workspace/dto/update-workspace.dto.ts + workspace.service.ts temporaryNoteHours
Клиент типы apps/client/src/features/page/types/page.types.ts, apps/client/src/features/page/tree/types.ts, IWorkspace temporaryExpiresAt?, temporaryNoteHours?
Создание (UI) space-sidebar.tsx, use-tree-mutation.ts вторая кнопка + проброс temporary
Символ space-tree-row.tsx индикатор-иконка
Меню space-tree-node-menu.tsx, page-header-menu.tsx пункт «Make permanent / Make temporary» + хук-мутация
Маппинг apps/client/src/features/page/tree/utils/utils.ts (buildTree) проброс поля в SpaceTreeNode
Настройки UI apps/client/src/pages/settings/workspace/ NumberInput «время жизни (часы)»
i18n apps/client/public/locales/{en-US,ru-RU}/translation.json новые строки

Поэтапный план реализации

  1. БД и типы: миграция (2 колонки + индекс) → db.d.tsbaseFields.
  2. Бэкенд-ядро: create-page.dto + расчёт дедлайна в create()ToggleTemporaryDto + эндпоинт toggle-temporary → обнуление дедлайна в restorePage.
  3. Фоновый сборщик: TemporaryNoteCleanupService + регистрация в page.module.
  4. Проброс в дерево: sidebar-pages-tree.util (сервер) → IPage/SpaceTreeNode + buildTree (клиент).
  5. UI: вторая кнопка создания → символ в строке дерева → пункт меню «Сделать постоянной» (дерево + хедер) → баннер.
  6. Настройка X: update-workspace.dto + audit-tracking + UI-редактор (часы).
  7. i18n и тесты (по образцу page-template.controller.spec.ts и trash-cleanup).

Issue является проектной проработкой (design-only). Исходный код в рамках этой задачи не изменялся.

## Концепция «Временная заметка» — заметка с «таймером смерти». Создаётся отдельной кнопкой; через настраиваемые **X часов** (по умолчанию 24) она автоматически уезжает **в корзину** тем же мягким удалением, что и при ручном «Удалить». Чтобы заметка выжила, нужно осознанное действие — **«Сделать постоянной»**. Не сделал — умерла. Девиз: *структурируй или умри*. Переиспользование существующих механизмов: - **пометка/символ** — как у шаблонов (флаг-колонка на `pages` → иконка в дереве → пункт меню-переключатель → toggle-эндпоинт), см. `is_template`; - **автоудаление** — как у очистки корзины: фоновый `@Interval`-сервис по образцу `TrashCleanupService`; - **само удаление** — существующий рекурсивный `PageRepo.removePage` (через корзину, не напрямую). ## Жизненный цикл (две стадии смерти) ``` создать «временную» +X часов (фоновый сборщик) [ нет заметки ] ───────────────▶ [ ВРЕМЕННАЯ ] ──────────────────────────▶ [ В КОРЗИНЕ ] expiresAt=t0+Xh removePage() ставит deletedAt=now │ deletedAt + emit │ PAGE_SOFT_DELETED «Сделать │ │ постоянной» ▼ trashRetentionDays │ (штатно) [ ОБЫЧНАЯ ] ▼ expiresAt = NULL [ УДАЛЕНА НАВСЕГДА ] (существующий TrashCleanupService) ``` Удаление двухстадийное: временная → (через X ч) корзина → (через `trashRetentionDays`) навсегда. Это даёт сетку безопасности — даже «умершую» заметку ещё можно восстановить из корзины. ## Зафиксированные решения 1. **Отдельная вторая кнопка** в шапке дерева пространства (standalone `ActionIcon` с иконкой песочных часов рядом с обычным «+»), а не split-меню. 2. **Дети уезжают в корзину вместе** с временной заметкой (штатное поведение рекурсивного `removePage`; восстановление возвращает всё поддерево). Поведение задокументировать в подсказке. 3. **UI-редактор времени жизни нужен**, значение задаётся **в часах** (не днях), на уровне workspace. ## Модель данных ### Решение: одна nullable-колонка-таймстемп вместо булева флага Добавляем `pages.temporary_expires_at timestamptz NULL`: - `NULL` → заметка обычная; - не-`NULL` → заметка временная, значение = точный момент «смерти». Признак «временная» = `temporary_expires_at IS NOT NULL` — он управляет и символом, и пунктом меню. Дедлайн **замораживается при создании** (`now + X ч`), поэтому изменение глобальной настройки X не «переигрывает» дедлайны уже созданных заметок. Новая миграция (по образцу `20260620T130000-page-is-template.ts`): ```ts // "Death timer" column. NULL = permanent page; non-NULL = temporary note. 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); ``` Настройка X часов — на workspace (по образцу `trash_retention_days`, `20260228T223532-audit.ts`): ```ts // Default lifetime for new temporary notes, in HOURS. Frozen per-note at creation. await db.schema .alterTable('workspaces') .addColumn('temporary_note_hours', 'int8', (col) => col) .execute(); ``` Дефолт при `NULL` — константа в коде: `DEFAULT_TEMPORARY_NOTE_HOURS = 24` (аналог `DEFAULT_RETENTION_DAYS = 30`). Сопутствующие правки типов: - `apps/server/src/database/types/db.d.ts`: `Pages.temporaryExpiresAt: Timestamp | null`; `Workspaces.temporaryNoteHours: Generated<number>`. - `apps/server/src/database/repos/page/page.repo.ts` → `baseFields`: добавить `'temporaryExpiresAt'`. - Клиент: `IPage` и `SpaceTreeNode` — `temporaryExpiresAt?: string | null`. ## Серверная часть ### 1. Создание временной заметки `apps/server/src/core/page/dto/create-page.dto.ts`: ```ts @IsOptional() @IsBoolean() temporary?: boolean; // when true, create the page as a temporary note ``` `page.service.ts` `create()` (перед `insertPage`): ```ts // Freeze the death timer here so later setting changes never reschedule existing notes. let temporaryExpiresAt: Date | undefined; if (createPageDto.temporary) { const hours = workspace.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS; temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000); } // ...insertPage({ ..., temporaryExpiresAt }) ``` ### 2. Переключатель «временная ⇄ постоянная» (toggle) Зеркало механизма шаблонов. Новый эндпоинт `POST /pages/toggle-temporary` (рядом с `toggle-template` в `page-template.controller.ts`), DTO по образцу `toggle-template.dto.ts`: ```ts export class ToggleTemporaryDto { @IsUUID() pageId!: string; // Omitted -> toggle relative to current state. // true -> arm timer (now + workspace hours); false -> clear (make permanent). @IsOptional() @IsBoolean() temporary?: boolean; } ``` Логика (зеркало `toggleTemplate`): проверки существования / `workspaceId` / `validateCanEdit`, затем: ```ts const makeTemporary = typeof dto.temporary === 'boolean' ? dto.temporary : page.temporaryExpiresAt == null; const temporaryExpiresAt = makeTemporary ? new Date(Date.now() + (workspace.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS) * 3600_000) : null; // "Make permanent" — structure and survive await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id); return { pageId: page.id, temporaryExpiresAt }; ``` Даёт сразу обе операции: **«Сделать постоянной»** (`temporary:false`) и симметричную **«Сделать временной»** для существующей заметки (как «Make template»). ### 3. Фоновый сборщик — `TemporaryNoteCleanupService` Калька `trash-cleanup.service.ts`; регистрируется в `page.module.ts` рядом с `TrashCleanupService`. `@nestjs/schedule` уже глобально включён (`TelemetryModule`). ```ts @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 in hours, so <1h overshoot is acceptable. @Interval('temporary-note-cleanup', 60 * 60 * 1000) async sweepExpiredTemporaryNotes() { 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(); for (const page of expired) { try { // Reuse the exact soft-delete path: recursive over children + emits PAGE_SOFT_DELETED. // Attribute the automatic deletion to the note's creator (no schema change). await this.pageRepo.removePage(page.id, page.creatorId, page.workspaceId); } catch (error) { this.logger.error(`Failed to trash expired temporary note ${page.id}`, error); } } } } ``` Используем `removePage`, а не новый код: он уже рекурсивно проставляет `deletedAt` детям, удаляет `shares` в транзакции и эмитит `PAGE_SOFT_DELETED` (инвалидация дерева, watcher-уведомления) — `page.repo.ts:296-378`. ### 4. Восстановление из корзины В `restorePage` (`page.repo.ts:380-439`) при восстановлении **обнулять `temporary_expires_at`** — иначе дедлайн в прошлом приведёт к мгновенному повторному удалению в ближайший час: ```ts // On restore, disarm the death timer: pulling a note out of trash means "keep it". .set({ deletedById: null, deletedAt: null, temporaryExpiresAt: null }) ``` ### 5. Настройка X часов (workspace) По образцу `trashRetentionDays`: поле в `update-workspace.dto.ts` (`@IsOptional() @IsInt() @Min(1)`), audit-tracking в `workspace.service.ts` `update()`, эндпоинт `POST /workspace/update` уже обрабатывает любые поля DTO. `temporaryNoteHours` добавить в `baseFields` workspace-репозитория. ## Клиентская часть ### 1. Отдельная вторая кнопка создания В шапке дерева `apps/client/src/features/space/components/sidebar/space-sidebar.tsx:114-123` рядом с обычным `IconPlus` добавить **вторую самостоятельную** `ActionIcon` с `IconHourglass` (tooltip «Новая временная заметка»), вызывающую `handleCreate(null, { temporary: true })`. Проброс флага в `use-tree-mutation.ts:121-182`: ```ts const handleCreate = useCallback( async (parentId: string | null, opts?: { temporary?: boolean }) => { const payload: { spaceId: string; parentPageId?: string; temporary?: boolean } = { spaceId }; if (parentId) payload.parentPageId = parentId; if (opts?.temporary) payload.temporary = true; // ask the server to arm the timer const createdPage = await createPageMutation.mutateAsync(payload); const newNode: SpaceTreeNode = { /* ...as before... */ temporaryExpiresAt: createdPage.temporaryExpiresAt, // show the symbol immediately }; // ... }, [/* deps */], ); ``` `create-page.dto` / `IPageInput` на клиенте — добавить опциональный `temporary?: boolean`. ### 2. Символ-пометка в дереве В `space-tree-row.tsx:175-192` рядом с индикатором шаблона добавить аналог для временной заметки (`@tabler/icons-react` v3.40 доступен; напр. `IconClockHour4` / `IconHourglass`, оранжевый цвет): ```tsx {node.temporaryExpiresAt && ( <Tooltip label={t("Temporary · expires {{when}}", { when: formatRelative(node.temporaryExpiresAt) })} withArrow> <IconClockHour4 size={14} stroke={1.5} style={{ flexShrink: 0, marginLeft: rem(4), color: "var(--mantine-color-orange-6)" }} aria-label={t("Temporary note")} role="img" /> </Tooltip> )} ``` Проброс поля с сервера в дерево: добавить `temporaryExpiresAt` в `SidebarPageRow` / `shapeSidebarPagesTree` (`sidebar-pages-tree.util.ts`) и в SELECT запроса sidebar-страниц; на клиенте — в маппинг `buildTree` (`apps/client/src/features/page/tree/utils/utils.ts`), как `isTemplate`. ### 3. Пункт меню «Сделать постоянной» Зеркало пункта «Make template» в `space-tree-node-menu.tsx:240-249` (хук `useToggleTemporaryMutation` по образцу `useToggleTemplateMutation`): ```tsx {canEdit && ( <Menu.Item leftSection={<IconClockHour4 size={16} />} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleToggleTemporary(); }} > {node.temporaryExpiresAt ? t("Make permanent") : t("Make temporary")} </Menu.Item> )} ``` Тот же пункт продублировать в меню открытой страницы `page-header-menu.tsx` (`PageActionMenu`, рядом с «Move to trash»). ### 4. Баннер на открытой временной заметке По образцу `trash-banner.tsx`: если у открытой страницы есть `temporaryExpiresAt`, показывать сверху предупреждение «⏳ Эта заметка переедет в корзину через N ч. Сделать постоянной?» с кнопкой действия. Усиливает философию «структурируй или умри» и даёт второй явный путь спасения. ### 5. UI-редактор времени жизни (в часах) Новый компонент в настройках workspace `apps/client/src/pages/settings/workspace/` (раздел General или новый «Retention»): `NumberInput` «Время жизни временной заметки (часы)», `min=1`, целое, сохранение через `POST /workspace/update` (`temporaryNoteHours`). Поле `temporaryNoteHours` добавить в клиентский `IWorkspace`. > Примечание: у существующей настройки `trashRetentionDays` UI-редактора сейчас нет — здесь делаем полноценный редактор, как просили. ### 6. i18n Строки в `apps/client/public/locales/en-US/translation.json` и `ru-RU/translation.json` (плоский «ключ → значение»): `Temporary note`, `Make temporary`, `Make permanent`, `New temporary note`, `Temporary · expires {{when}}`, текст баннера, подпись настройки времени жизни. ## Краевые случаи и решения | Случай | Решение | |---|---| | **Дочерние страницы у временной заметки** | Уезжают в корзину вместе с родителем (рекурсивный `removePage`). Восстановление возвращает поддерево. **Решение зафиксировано** (см. выше). Задокументировать в подсказке. | | **Дедлайн в прошлом после восстановления** | На `restorePage` обнуляем `temporary_expires_at` → заметка становится постоянной. | | **Кто «удалил» при автоудалении** | Передаём `creatorId` в `removePage` — без изменения схемы; в корзине видно «удалил <автор>». | | **Смена глобальной настройки X** | Не влияет на существующие заметки (дедлайн заморожен при создании), только на новые. | | **Гонка / повторное срабатывание** | Запрос фильтрует `deletedAt IS NULL`, `removePage` тоже фильтрует `deletedAt IS NULL` в `WHERE` → идемпотентно. | | **Точность срабатывания** | Сборщик раз в час → заметка может прожить до ~1 ч сверх дедлайна. Приемлемо для часовой гранулярности. | | **Права** | Toggle защищён `validateCanEdit` (как у шаблонов); создание — существующими проверками create. | ## Карта точек интеграции | Слой | Файл | Что добавить | |---|---|---| | Миграция | новый файл в `apps/server/src/database/migrations/` | колонки `pages.temporary_expires_at`, `workspaces.temporary_note_hours` + partial index | | Типы БД | `apps/server/src/database/types/db.d.ts` | поля в `Pages` и `Workspaces` | | Repo | `apps/server/src/database/repos/page/page.repo.ts` | `temporaryExpiresAt` в `baseFields`; обнуление в `restorePage` | | Create DTO | `apps/server/src/core/page/dto/create-page.dto.ts` | `temporary?: boolean` | | Create service | `apps/server/src/core/page/services/page.service.ts` | расчёт `temporaryExpiresAt` | | Toggle | `apps/server/src/core/page/transclusion/page-template.controller.ts` + новый `ToggleTemporaryDto` | `POST /pages/toggle-temporary` | | Сборщик | новый `temporary-note-cleanup.service.ts` + `apps/server/src/core/page/page.module.ts` | `@Interval`-сервис | | Sidebar shape | `apps/server/src/core/page/services/sidebar-pages-tree.util.ts` | проброс `temporaryExpiresAt` | | Workspace настройка | `apps/server/src/core/workspace/dto/update-workspace.dto.ts` + `workspace.service.ts` | `temporaryNoteHours` | | Клиент типы | `apps/client/src/features/page/types/page.types.ts`, `apps/client/src/features/page/tree/types.ts`, `IWorkspace` | `temporaryExpiresAt?`, `temporaryNoteHours?` | | Создание (UI) | `space-sidebar.tsx`, `use-tree-mutation.ts` | вторая кнопка + проброс `temporary` | | Символ | `space-tree-row.tsx` | индикатор-иконка | | Меню | `space-tree-node-menu.tsx`, `page-header-menu.tsx` | пункт «Make permanent / Make temporary» + хук-мутация | | Маппинг | `apps/client/src/features/page/tree/utils/utils.ts` (`buildTree`) | проброс поля в `SpaceTreeNode` | | Настройки UI | `apps/client/src/pages/settings/workspace/` | `NumberInput` «время жизни (часы)» | | i18n | `apps/client/public/locales/{en-US,ru-RU}/translation.json` | новые строки | ## Поэтапный план реализации 1. **БД и типы:** миграция (2 колонки + индекс) → `db.d.ts` → `baseFields`. 2. **Бэкенд-ядро:** `create-page.dto` + расчёт дедлайна в `create()` → `ToggleTemporaryDto` + эндпоинт `toggle-temporary` → обнуление дедлайна в `restorePage`. 3. **Фоновый сборщик:** `TemporaryNoteCleanupService` + регистрация в `page.module`. 4. **Проброс в дерево:** `sidebar-pages-tree.util` (сервер) → `IPage`/`SpaceTreeNode` + `buildTree` (клиент). 5. **UI:** вторая кнопка создания → символ в строке дерева → пункт меню «Сделать постоянной» (дерево + хедер) → баннер. 6. **Настройка X:** `update-workspace.dto` + audit-tracking + UI-редактор (часы). 7. **i18n** и тесты (по образцу `page-template.controller.spec.ts` и `trash-cleanup`). --- *Issue является проектной проработкой (design-only). Исходный код в рамках этой задачи не изменялся.*
Ghost added the feature label 2026-06-25 23:47:52 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#201