feat(page): temporary notes — auto-trash after X hours unless made permanent (#201) #215

Merged
vvzvlad merged 4 commits from feat/201-temporary-notes into develop 2026-06-26 20:54:56 +03:00

Closes #201

"Temporary notes" — a note with a death timer. Created via a dedicated hourglass button in the space-tree header; after a configurable X hours (default 24) it auto-moves to Trash via the same soft-delete as a manual "Move to trash". To survive it needs an explicit "Make permanent". Motto: structure or die.

Reused mechanisms (mirrors)

  • Flag/symbol like is_template: nullable column pages.temporary_expires_at (NULL = permanent; non-NULL = frozen deadline) -> tree icon -> menu toggle -> toggle endpoint.
  • Auto-delete like trash-cleanup: an @Interval service modelled on TrashCleanupService.
  • The delete itself: the existing recursive PageRepo.removePage (children ride along to trash; emits PAGE_SOFT_DELETED).

Server

  • Migration 20260626T130000-page-temporary-notes: pages.temporary_expires_at timestamptz NULL + partial index pages_temporary_expires_at_idx (armed & not-yet-trashed); workspaces.temporary_note_hours int8 NULL. Default in code: DEFAULT_TEMPORARY_NOTE_HOURS = 24.
  • create-page DTO temporary?: boolean; the deadline is frozen at creation, so changing the workspace setting never reschedules existing notes.
  • POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer, CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
  • TemporaryNoteCleanupService: hourly @Interval sweep (temporary-note-cleanup, every 60 min) that soft-deletes expired notes through removePage, attributed to the creator; idempotent (deletedAt IS NULL on both the SELECT and removePage).
  • restorePage clears temporary_expires_at so a restored note is not instantly re-trashed.
  • Workspace setting temporaryNoteHours (audit-tracked) + repo baseFields.

Client

  • Standalone second create button (hourglass) in the space-tree header.
  • Orange IconClockHour4 indicator in the tree row.
  • "Make temporary" / "Make permanent" toggle in the tree node menu and the page-header menu.
  • Banner on an open temporary note with a "Make permanent" rescue action.
  • Workspace General settings: NumberInput "Temporary note lifetime (hours)".
  • en/ru i18n strings.

Migration name

20260626T130000-page-temporary-notes.ts

Cleanup-job cadence

@Interval('temporary-note-cleanup', 60 * 60 * 1000) — every hour (hourly granularity; a note may live up to ~1h past its deadline, by design).

Tests (unit only)

  • page-temporary.controller.spec.ts: toggle/explicit/permission/cross-workspace + ToggleTemporaryDto validation.
  • temporary-note-cleanup.service.spec.ts: selection filters, per-note removePage (attributed to creator), error isolation, no-op.
  • 20260626T130000-page-temporary-notes.spec.ts: migration up/down sanity (columns + partial index).

Verify

  • pnpm --filter server exec tsc --noEmit — clean.
  • pnpm --filter client exec tsc -b — clean.
  • jest src/core/page src/core/workspace — 234 passed (29 suites). test:int not run (shared DB).

🤖 Generated with Claude Code

Closes #201 "Temporary notes" — a note with a death timer. Created via a dedicated hourglass button in the space-tree header; after a configurable X hours (default 24) it auto-moves to Trash via the same soft-delete as a manual "Move to trash". To survive it needs an explicit "Make permanent". Motto: structure or die. ## Reused mechanisms (mirrors) - **Flag/symbol** like `is_template`: nullable column `pages.temporary_expires_at` (NULL = permanent; non-NULL = frozen deadline) -> tree icon -> menu toggle -> toggle endpoint. - **Auto-delete** like trash-cleanup: an `@Interval` service modelled on `TrashCleanupService`. - **The delete itself**: the existing recursive `PageRepo.removePage` (children ride along to trash; emits `PAGE_SOFT_DELETED`). ## Server - Migration `20260626T130000-page-temporary-notes`: `pages.temporary_expires_at timestamptz NULL` + partial index `pages_temporary_expires_at_idx` (armed & not-yet-trashed); `workspaces.temporary_note_hours int8 NULL`. Default in code: `DEFAULT_TEMPORARY_NOTE_HOURS = 24`. - `create-page` DTO `temporary?: boolean`; the deadline is frozen at creation, so changing the workspace setting never reschedules existing notes. - `POST /pages/toggle-temporary` (mirror of `toggle-template`): arm/clear the timer, CASL-guarded via `validateCanEdit`, cross-workspace `NotFound` defense-in-depth. - `TemporaryNoteCleanupService`: hourly `@Interval` sweep (`temporary-note-cleanup`, every 60 min) that soft-deletes expired notes through `removePage`, attributed to the creator; idempotent (`deletedAt IS NULL` on both the SELECT and removePage). - `restorePage` clears `temporary_expires_at` so a restored note is not instantly re-trashed. - Workspace setting `temporaryNoteHours` (audit-tracked) + repo baseFields. ## Client - Standalone second create button (hourglass) in the space-tree header. - Orange `IconClockHour4` indicator in the tree row. - "Make temporary" / "Make permanent" toggle in the tree node menu and the page-header menu. - Banner on an open temporary note with a "Make permanent" rescue action. - Workspace General settings: NumberInput "Temporary note lifetime (hours)". - en/ru i18n strings. ## Migration name `20260626T130000-page-temporary-notes.ts` ## Cleanup-job cadence `@Interval('temporary-note-cleanup', 60 * 60 * 1000)` — every hour (hourly granularity; a note may live up to ~1h past its deadline, by design). ## Tests (unit only) - `page-temporary.controller.spec.ts`: toggle/explicit/permission/cross-workspace + `ToggleTemporaryDto` validation. - `temporary-note-cleanup.service.spec.ts`: selection filters, per-note `removePage` (attributed to creator), error isolation, no-op. - `20260626T130000-page-temporary-notes.spec.ts`: migration up/down sanity (columns + partial index). ## Verify - `pnpm --filter server exec tsc --noEmit` — clean. - `pnpm --filter client exec tsc -b` — clean. - `jest src/core/page src/core/workspace` — 234 passed (29 suites). `test:int` not run (shared DB). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 06:35:12 +03:00
"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").

Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
  frozen deadline) + partial index for the sweep; workspace column
  temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
  setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
  CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
  notes through the exact PageRepo.removePage path (recursive over children,
  emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
  deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
  workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
  ("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
  and en/ru i18n.

Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 15:49:29 +03:00
Owner

Code review — PR #215: временные заметки — авто-перенос в корзину через X часов, если не сделать постоянной

Вердикт: Approve with comments. Блокирующих дефектов в логике нет: data-loss-race на «make permanent во время свипа» восстановим (restore разоружает таймер — проверено), поэтому это warning, а не блокер. Единственное обязательное к мержу — фид user-facing фичи в CHANGELOG (нарушение установленной конвенции репо) и тесты на новую data-loss-чувствительную логику (frozen-deadline в create() и сброс таймера в restorePage), которые сейчас отсутствуют.

Объём: дифф developfeat/201-temporary-notes (merge-base 3ddc329b), 35 файлов, +1063/−17. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

  • [documentation] Добавить запись в CHANGELOG [Unreleased] / ### Added про временные заметки (#201)CHANGELOG.md:11-13
    Репо ведёт CHANGELOG в формате Keep-a-Changelog с живой секцией [Unreleased]/Added, где каждая недавняя user-facing фича документируется со ссылкой на issue (#143, #168, #180, #183 и т.д.); AGENTS.md и предыдущие review-фиксы (f80276d4) делают обновление changelog частью merge-процесса. Дифф не трогает CHANGELOG.md (подтверждено пустым диффом), хотя PR добавляет новый workspace-сеттинг, эндпойнт POST /pages/toggle-temporary, миграцию БД, фоновый свип и баннер — поведение «заметка сама уходит в корзину через X часов» операторы обязаны знать. Fix: добавить буллет под ## [Unreleased]### Added: временные заметки авто-уходят в корзину через настраиваемый workspace-лайфтайм (дефолт DEFAULT_TEMPORARY_NOTE_HOURS = 24ч), если не сделать постоянной; дедлайн фиксируется при создании; restore разоружает таймер; ссылка (#201), формат как у существующих записей.

  • [stability] Перепроверять temporaryExpiresAt в момент удаления, чтобы свип не перетирал параллельный «Make permanent»apps/server/src/core/page/services/temporary-note-cleanup.service.ts:29-58
    Свип делает нетранзакционный SELECT всех просроченных заметок, затем в цикле вызывает pageRepo.removePage(). removePage удаляет по id с фильтром только deletedAt IS NULL и НЕ перечитывает temporaryExpiresAt (проверено в page.repo.ts:297-355). Если пользователь нажимает «Make permanent» (toggleTemporary ставит temporaryExpiresAt = null) в окне между SELECT и конкретным removePage, заметку всё равно мягко удалит в корзину. Комментарий сервиса аргументирует идемпотентность только для двойного свипа (deletedAt-фильтр), но не для гонки keep-vs-trash. Не блокирует: восстановимо через restore, который сбрасывает таймер (подтверждено), окно узкое. Fix: сделать удаление в свипе условным — либо отдельный guarded soft-delete с WHERE temporary_expires_at IS NOT NULL AND temporary_expires_at < now AND deleted_at IS NULL (event/children — только при реально затронутой строке), либо в цикле перечитывать строку и пропускать removePage, если temporaryExpiresAt стал null. Добавить тест на это переплетение.

  • [stability] Ограничить выборку просроченных заметок (LIMIT/батчи), чтобы не грузить неограниченный бэклог в памятьapps/server/src/core/page/services/temporary-note-cleanup.service.ts:29-37
    SELECT тянет все просроченные заметки по всем workspace без LIMIT, затем последовательно делает рекурсивное удаление + emit события на каждую. При коротком temporaryNoteHours и массовом создании (например после простоя) бэклог может быть большим. Повторяет паттерн TrashCleanupService, поэтому не регрессия. Fix: добавить разумный LIMIT и дренировать остаток на следующем часовом прогоне, либо обрабатывать фиксированными батчами.

  • [simplification] Вынести дублированный пост-toggle sync кэша страниц в общий хелперapps/client/src/features/page/components/header/page-header-menu.tsx:175-189
    Тот же блок обновления кэша (for (const key of [page.slugId, page.id]) setQueryData(['pages', key], {...cached, temporaryExpiresAt}) + invalidateQueries('sidebar-pages')) скопирован дословно в temporary-note-banner.tsx:44-56. Две копии плумбинга ключей кэша легко разъезжаются. Fix: вынести в syncTemporaryExpiresInCache(queryClient, page, temporaryExpiresAt) и вызывать из меню-хедера и баннера.

  • [simplification] Убрать или утончить тавтологичный спек миграции, мокающий всю БДapps/server/src/database/migrations/spec/20260626T130000-page-temporary-notes.spec.ts:1-88
    Единственный migration-спек в репо (конвенции нет); он стабит весь Kysely schema builder и sql-тег, затем проверяет, что миграция вызывает те же билдер-методы, что видны строкой ниже. Без реальной БД он не ловит реальный дефект миграции (тип колонки, nullability, семантика предиката индекса, порядок) — только повторяет реализацию, добавляя стоимость поддержки. Fix: удалить спек, либо заменить на реальный интеграционный тест (применить up/down к тестовому Postgres и проверить итоговые колонки/индекс с правильными типами).

Test coverage

Свип (TemporaryNoteCleanupService) покрыт. Две новые data-loss-чувствительные ветки покрытия не имеют — это first-class находки:

  • [test-coverage] Добавить тест create() с temporary, проверяющий frozen temporaryExpiresAt в payload insertPageapps/server/src/core/page/services/page.service.ts:127-138, 173
    Новая ветка условно читает workspaces.temporaryNoteHours, падает в DEFAULT_TEMPORARY_NOTE_HOURS (24) при NULL, считает now + hours*3600000 и пишет на payload — ничего из этого не проверяется (page.service.spec.ts имеет харнесс create() → insertPage, но run() не ставит temporary). Регрессия в единице/фоллбэке/арифметике уедет зелёной. Проект уже тестирует payload insertPage (provenance), так что это в рамках планки репо. Fix: расширить describe create() → insertPage кейсами с { title, spaceId, temporary: true } и db-стабом, где workspaces select резолвит (a) строку с override и (b) { temporaryNoteHours: null }; с jest fake timers проверить temporaryExpiresAt === now + hours*60*60*1000 (override и DEFAULT на NULL); плюс кейс без temporarytemporaryExpiresAt undefined и без workspace-lookup.

  • [test-coverage] Добавить регрессионный тест, что restorePage сбрасывает temporaryExpiresAtapps/server/src/database/repos/page/page.repo.ts:426-433
    restorePage теперь ставит temporaryExpiresAt: null вместе с deletedById/deletedAt (подтверждено) — комментарий в диффе прямо объясняет: иначе восстановленная заметка с прошедшим дедлайном будет мгновенно ре-затрэшена следующим свипом. Тестов на restore в репо нет, будущий рефактор молча уронит сброс и вернёт баг. Fix: добавить unit-тест (новый restore-спек либо стаб update-билдера), проверяющий, что payload .set(...) включает temporaryExpiresAt: null (в дополнение к deletedById: null, deletedAt: null); chainable-стаб, записывающий аргумент set(), по образцу makeDbStub из temporary-note-cleanup.service.spec.ts, достаточен.

Architecture & design (forward-looking, non-blocking)

Дублирование вычисления дедлайна + DB-зависимость в контроллереpage.service.ts:127-138 (create) vs page-template.controller.ts:122-134 (toggleTemporary). Логика «взвести таймер смерти» (lookup temporaryNoteHours → fallback DEFAULT_TEMPORARY_NOTE_HOURSnew Date(Date.now() + hours*60*60*1000)) написана дословно дважды (оба call-site проверены). Чтобы сделать это в контроллере, PR инжектит @InjectKysely() прямо в PageTemplateController — ни один другой контроллер под apps/server/src/core не инжектит Kysely напрямую, остальные делегируют персистентность сервисам.

добавить PageService.computeTemporaryExpiresAt(workspaceId): Promise<Date>, вызывать из create() и контроллера, убрать @InjectKysely из PageTemplateController. Pros: единый источник формулы и NULL→default фоллбэка; контроллер возвращается к делегированию как у соседей; убирает нетипичный DB-инжект в контроллере; тривиально юнит-тестируется. Cons: ещё один маленький метод на и так большом PageService; toggleTemporary всё равно держит page-found/cross-workspace/CASL-проверки в контроллере, переезжает только compute+persist.

## Code review — PR #215: временные заметки — авто-перенос в корзину через X часов, если не сделать постоянной **Вердикт: Approve with comments.** Блокирующих дефектов в логике нет: data-loss-race на «make permanent во время свипа» восстановим (restore разоружает таймер — проверено), поэтому это warning, а не блокер. Единственное обязательное к мержу — фид user-facing фичи в CHANGELOG (нарушение установленной конвенции репо) и тесты на новую data-loss-чувствительную логику (frozen-deadline в `create()` и сброс таймера в `restorePage`), которые сейчас отсутствуют. _Объём: дифф `develop`…`feat/201-temporary-notes` (merge-base `3ddc329b`), 35 файлов, +1063/−17. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge - **[documentation] Добавить запись в CHANGELOG `[Unreleased] / ### Added` про временные заметки (#201)** — `CHANGELOG.md:11-13` Репо ведёт CHANGELOG в формате Keep-a-Changelog с живой секцией `[Unreleased]/Added`, где каждая недавняя user-facing фича документируется со ссылкой на issue (#143, #168, #180, #183 и т.д.); AGENTS.md и предыдущие review-фиксы (`f80276d4`) делают обновление changelog частью merge-процесса. Дифф не трогает CHANGELOG.md (подтверждено пустым диффом), хотя PR добавляет новый workspace-сеттинг, эндпойнт `POST /pages/toggle-temporary`, миграцию БД, фоновый свип и баннер — поведение «заметка сама уходит в корзину через X часов» операторы обязаны знать. Fix: добавить буллет под `## [Unreleased]` → `### Added`: временные заметки авто-уходят в корзину через настраиваемый workspace-лайфтайм (дефолт `DEFAULT_TEMPORARY_NOTE_HOURS` = 24ч), если не сделать постоянной; дедлайн фиксируется при создании; restore разоружает таймер; ссылка (#201), формат как у существующих записей. - **[stability] Перепроверять `temporaryExpiresAt` в момент удаления, чтобы свип не перетирал параллельный «Make permanent»** — `apps/server/src/core/page/services/temporary-note-cleanup.service.ts:29-58` Свип делает нетранзакционный SELECT всех просроченных заметок, затем в цикле вызывает `pageRepo.removePage()`. `removePage` удаляет по `id` с фильтром только `deletedAt IS NULL` и НЕ перечитывает `temporaryExpiresAt` (проверено в page.repo.ts:297-355). Если пользователь нажимает «Make permanent» (`toggleTemporary` ставит `temporaryExpiresAt = null`) в окне между SELECT и конкретным `removePage`, заметку всё равно мягко удалит в корзину. Комментарий сервиса аргументирует идемпотентность только для двойного свипа (`deletedAt`-фильтр), но не для гонки keep-vs-trash. Не блокирует: восстановимо через restore, который сбрасывает таймер (подтверждено), окно узкое. Fix: сделать удаление в свипе условным — либо отдельный guarded soft-delete с `WHERE temporary_expires_at IS NOT NULL AND temporary_expires_at < now AND deleted_at IS NULL` (event/children — только при реально затронутой строке), либо в цикле перечитывать строку и пропускать `removePage`, если `temporaryExpiresAt` стал `null`. Добавить тест на это переплетение. - **[stability] Ограничить выборку просроченных заметок (LIMIT/батчи), чтобы не грузить неограниченный бэклог в память** — `apps/server/src/core/page/services/temporary-note-cleanup.service.ts:29-37` SELECT тянет все просроченные заметки по всем workspace без LIMIT, затем последовательно делает рекурсивное удаление + emit события на каждую. При коротком `temporaryNoteHours` и массовом создании (например после простоя) бэклог может быть большим. Повторяет паттерн `TrashCleanupService`, поэтому не регрессия. Fix: добавить разумный LIMIT и дренировать остаток на следующем часовом прогоне, либо обрабатывать фиксированными батчами. - **[simplification] Вынести дублированный пост-toggle sync кэша страниц в общий хелпер** — `apps/client/src/features/page/components/header/page-header-menu.tsx:175-189` Тот же блок обновления кэша (`for (const key of [page.slugId, page.id]) setQueryData(['pages', key], {...cached, temporaryExpiresAt})` + `invalidateQueries('sidebar-pages')`) скопирован дословно в `temporary-note-banner.tsx:44-56`. Две копии плумбинга ключей кэша легко разъезжаются. Fix: вынести в `syncTemporaryExpiresInCache(queryClient, page, temporaryExpiresAt)` и вызывать из меню-хедера и баннера. - **[simplification] Убрать или утончить тавтологичный спек миграции, мокающий всю БД** — `apps/server/src/database/migrations/spec/20260626T130000-page-temporary-notes.spec.ts:1-88` Единственный migration-спек в репо (конвенции нет); он стабит весь Kysely schema builder и `sql`-тег, затем проверяет, что миграция вызывает те же билдер-методы, что видны строкой ниже. Без реальной БД он не ловит реальный дефект миграции (тип колонки, nullability, семантика предиката индекса, порядок) — только повторяет реализацию, добавляя стоимость поддержки. Fix: удалить спек, либо заменить на реальный интеграционный тест (применить up/down к тестовому Postgres и проверить итоговые колонки/индекс с правильными типами). ### Test coverage Свип (`TemporaryNoteCleanupService`) покрыт. Две новые data-loss-чувствительные ветки покрытия не имеют — это first-class находки: - **[test-coverage] Добавить тест `create()` с `temporary`, проверяющий frozen `temporaryExpiresAt` в payload `insertPage`** — `apps/server/src/core/page/services/page.service.ts:127-138, 173` Новая ветка условно читает `workspaces.temporaryNoteHours`, падает в `DEFAULT_TEMPORARY_NOTE_HOURS` (24) при NULL, считает `now + hours*3600000` и пишет на payload — ничего из этого не проверяется (`page.service.spec.ts` имеет харнесс `create() → insertPage`, но `run()` не ставит `temporary`). Регрессия в единице/фоллбэке/арифметике уедет зелёной. Проект уже тестирует payload `insertPage` (provenance), так что это в рамках планки репо. Fix: расширить describe `create() → insertPage` кейсами с `{ title, spaceId, temporary: true }` и db-стабом, где `workspaces` select резолвит (a) строку с override и (b) `{ temporaryNoteHours: null }`; с jest fake timers проверить `temporaryExpiresAt === now + hours*60*60*1000` (override и DEFAULT на NULL); плюс кейс без `temporary` — `temporaryExpiresAt` undefined и без workspace-lookup. - **[test-coverage] Добавить регрессионный тест, что `restorePage` сбрасывает `temporaryExpiresAt`** — `apps/server/src/database/repos/page/page.repo.ts:426-433` `restorePage` теперь ставит `temporaryExpiresAt: null` вместе с `deletedById/deletedAt` (подтверждено) — комментарий в диффе прямо объясняет: иначе восстановленная заметка с прошедшим дедлайном будет мгновенно ре-затрэшена следующим свипом. Тестов на restore в репо нет, будущий рефактор молча уронит сброс и вернёт баг. Fix: добавить unit-тест (новый restore-спек либо стаб update-билдера), проверяющий, что payload `.set(...)` включает `temporaryExpiresAt: null` (в дополнение к `deletedById: null, deletedAt: null`); chainable-стаб, записывающий аргумент `set()`, по образцу `makeDbStub` из `temporary-note-cleanup.service.spec.ts`, достаточен. ### Architecture & design (forward-looking, non-blocking) **Дублирование вычисления дедлайна + DB-зависимость в контроллере** — `page.service.ts:127-138` (create) vs `page-template.controller.ts:122-134` (toggleTemporary). Логика «взвести таймер смерти» (lookup `temporaryNoteHours` → fallback `DEFAULT_TEMPORARY_NOTE_HOURS` → `new Date(Date.now() + hours*60*60*1000)`) написана дословно дважды (оба call-site проверены). Чтобы сделать это в контроллере, PR инжектит `@InjectKysely()` прямо в `PageTemplateController` — ни один другой контроллер под `apps/server/src/core` не инжектит Kysely напрямую, остальные делегируют персистентность сервисам. добавить `PageService.computeTemporaryExpiresAt(workspaceId): Promise<Date>`, вызывать из `create()` и контроллера, убрать `@InjectKysely` из `PageTemplateController`. Pros: единый источник формулы и NULL→default фоллбэка; контроллер возвращается к делегированию как у соседей; убирает нетипичный DB-инжект в контроллере; тривиально юнит-тестируется. Cons: ещё один маленький метод на и так большом `PageService`; `toggleTemporary` всё равно держит page-found/cross-workspace/CASL-проверки в контроллере, переезжает только compute+persist.
Ghost added 1 commit 2026-06-26 17:23:18 +03:00
Must-fix:
- CHANGELOG: add [Unreleased]/Added entry for temporary notes (#201).
- temporary-note-cleanup: re-check temporary_expires_at at deletion time so a
  concurrent "Make permanent" (sets it NULL) between the batch SELECT and the
  per-row removePage wins the race and the note is not trashed. Add unit tests
  for the make-permanent and already-trashed race windows.

Non-blocking review items:
- temporary-note-cleanup: cap the sweep batch (LIMIT 500) so a large backlog is
  not loaded into memory; remainder drains on the next hourly run.
- client: extract duplicated post-toggle cache sync into
  syncTemporaryExpiresInCache() shared by the header menu and the banner.
- Remove the tautological migration spec that mocked the whole Kysely builder.
- Tests: cover create() frozen temporaryExpiresAt (workspace override + NULL
  default fallback + non-temporary skips lookup) and restorePage disarming the
  timer (temporaryExpiresAt: null).

Deferred (forward-looking, non-blocking): extract
PageService.computeTemporaryExpiresAt() to dedupe the deadline formula and drop
the @InjectKysely from PageTemplateController; replace migration unit test with
a real Postgres up/down integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review (re-review) — PR #215: temporary notes — auto-trash after X hours (#201)

Вердикт: Approve with comments. Единственный прошлый блокер (отсутствующая запись в CHANGELOG для #201) закрыт по существу; в дельте новых блокеров нет. Остаётся одна непокрытая ветка race-guard'а — non-blocking.

Ре-ревью дельты a9b53d27..4f82be6e (9 файлов, +266/−120). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge).

Статус прошлых блокеров

  • CHANGELOG [Unreleased]/Added для #201 — ЗАКРЫТ. В CHANGELOG.md под ## [Unreleased]### Added добавлена реальная запись «Temporary notes — auto-move to Trash after a workspace lifetime», корректно описывающая поведение (фиксация дедлайна на момент создания, почасовой sweep, дети едут следом, баннер «Make permanent», disarm при восстановлении из Trash) и завершающаяся (#201). Секция и формулировка верны, не косметика.

Must fix before merge

  • [test-coverage] Добавить тест на ветку «перевооружён на будущий дедлайн» в guard'е удаленияapps/server/src/core/page/services/spec/temporary-note-cleanup.service.spec.ts:95-128 Guard в temporary-note-cleanup.service.ts пропускает заметку при любом из четырёх условий: !current, deletedAt !== null, temporaryExpiresAt === null, new Date(temporaryExpiresAt) >= now. Тесты покрывают made-permanent и already-trashed, но ветку >= now (пользователь disarm-then-re-arm или повторно включил таймер в окне между батч-SELECT и per-row re-read, получив свежий будущий дедлайн) — нет. Дефолтный stub отдаёт new Date(0) (всегда < now), поэтому регрессия, ломающая именно это сравнение (>=> или потеря обёртки new Date(...)), пройдёт весь сьют незамеченной. Это сердце дельты — защита от перевооружения во время sweep'а. Fix: добавить кейс по образцу существующих race-тестов — переопределить reReadFirst на { temporaryExpiresAt: new Date(Date.now() + 60*60*1000), deletedAt: null } и проверить, что pageRepo.removePage не вызван.
## Code review (re-review) — PR #215: temporary notes — auto-trash after X hours (#201) **Вердикт: Approve with comments.** Единственный прошлый блокер (отсутствующая запись в CHANGELOG для #201) закрыт по существу; в дельте новых блокеров нет. Остаётся одна непокрытая ветка race-guard'а — non-blocking. _Ре-ревью дельты `a9b53d27..4f82be6e` (9 файлов, +266/−120). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **CHANGELOG `[Unreleased]`/`Added` для #201 — ЗАКРЫТ.** В `CHANGELOG.md` под `## [Unreleased]` → `### Added` добавлена реальная запись «Temporary notes — auto-move to Trash after a workspace lifetime», корректно описывающая поведение (фиксация дедлайна на момент создания, почасовой sweep, дети едут следом, баннер «Make permanent», disarm при восстановлении из Trash) и завершающаяся `(#201)`. Секция и формулировка верны, не косметика. ### Must fix before merge - **[test-coverage] Добавить тест на ветку «перевооружён на будущий дедлайн» в guard'е удаления** — `apps/server/src/core/page/services/spec/temporary-note-cleanup.service.spec.ts:95-128` Guard в `temporary-note-cleanup.service.ts` пропускает заметку при любом из четырёх условий: `!current`, `deletedAt !== null`, `temporaryExpiresAt === null`, `new Date(temporaryExpiresAt) >= now`. Тесты покрывают made-permanent и already-trashed, но ветку `>= now` (пользователь disarm-then-re-arm или повторно включил таймер в окне между батч-SELECT и per-row re-read, получив свежий будущий дедлайн) — нет. Дефолтный stub отдаёт `new Date(0)` (всегда `< now`), поэтому регрессия, ломающая именно это сравнение (`>=` → `>` или потеря обёртки `new Date(...)`), пройдёт весь сьют незамеченной. Это сердце дельты — защита от перевооружения во время sweep'а. Fix: добавить кейс по образцу существующих race-тестов — переопределить `reReadFirst` на `{ temporaryExpiresAt: new Date(Date.now() + 60*60*1000), deletedAt: null }` и проверить, что `pageRepo.removePage` не вызван.
Ghost added 1 commit 2026-06-26 18:06:03 +03:00
The deletion guard skips a note when its re-read deadline is still in the
future (user disarmed-then-re-armed in the race window between the batch
SELECT and the per-row re-read). The default stub returns an epoch deadline
(always < now), so the existing race tests never exercised the
`new Date(temporaryExpiresAt) >= now` branch; a regression dropping it or
inverting the comparison would pass unnoticed. Add a test that re-reads a
fresh future deadline and asserts removePage is not called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost force-pushed feat/201-temporary-notes from 83f64583e5 to 53cbec9354 2026-06-26 20:41:54 +03:00 Compare
vvzvlad merged commit d971d02346 into develop 2026-06-26 20:54:56 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#215