Compare commits
35 Commits
feat/198-i
...
fix/share-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767ac9e7e2 | ||
|
|
309719abc6 | ||
|
|
e682bbccd1 | ||
|
|
9d2bec8eb8 | ||
| b6630deb32 | |||
|
|
7ef98a663b | ||
| 109ab10fc5 | |||
|
|
2b7c861f78 | ||
|
|
d181b5c4ff | ||
|
|
12ff76fb89 | ||
|
|
26ca19f89e | ||
|
|
50e79275e1 | ||
|
|
8be8279809 | ||
|
|
19f84ca0e7 | ||
|
|
e9409e245b | ||
|
|
fa6a87e22d | ||
|
|
0fc9c4a998 | ||
|
|
40b8f7922a | ||
| 08c70cf550 | |||
|
|
ae6ed76d9a | ||
|
|
276ccc0783 | ||
|
|
406921ac6a | ||
| 719bccd80d | |||
| 83e64bad1a | |||
| ee78a96803 | |||
| d971d02346 | |||
|
|
53cbec9354 | ||
|
|
7d64b11045 | ||
|
|
983f2fa654 | ||
|
|
e99c00a9ee | ||
|
|
1f459d8d26 | ||
|
|
9632146d23 | ||
|
|
0314416bfa | ||
|
|
001ebe2e53 | ||
|
|
eb5b696431 |
@@ -132,6 +132,13 @@ MCP_DOCMOST_PASSWORD=
|
||||
# NEVER set is_agent on a human or shared account — every action by that account
|
||||
# (including normal human edits) would then be mis-attributed as AI.
|
||||
|
||||
# Agent-roles catalog source: an http(s):// base URL => the catalog is fetched
|
||||
# remotely (e.g. the raw GitHub base URL of the catalog repo); any other value
|
||||
# => a local filesystem directory. Empty (default) => the in-repo
|
||||
# ./agent-roles-catalog folder (dev). Used by the admin "import role from
|
||||
# catalog" feature only.
|
||||
# AI_AGENT_ROLES_CATALOG_URL=
|
||||
|
||||
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
||||
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
||||
# AI_EMBEDDING_TIMEOUT_MS=120000
|
||||
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Quick-create regular and temporary notes from the Home and Space screens.**
|
||||
The Home screen now shows a second action next to "New note" that creates a
|
||||
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
|
||||
resolving the target space the same way the regular button does — created
|
||||
directly when you can write to a single space, or via a space picker when
|
||||
several. Each space overview screen gains two buttons — "New note" and "New
|
||||
temporary note" — that create the page directly in that space and open it,
|
||||
mirroring the existing space-sidebar actions and shown only to members who can
|
||||
manage pages.
|
||||
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
|
||||
message gains a "send now" action that interrupts the streaming turn and
|
||||
immediately sends that message, keeping the agent's partial output. The
|
||||
@@ -19,6 +28,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
answer was cut off and builds on it instead of restarting; the rest of the
|
||||
queue still flushes normally afterward. (#198)
|
||||
|
||||
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
|
||||
catalog of agent roles, grouped into bundles and offered in several languages,
|
||||
and import the ones they want into the workspace (with skip-or-rename handling
|
||||
for name collisions); the same role in a different language imports as a
|
||||
separate install. An imported role remembers its catalog origin and offers a
|
||||
one-click update when the catalog ships a newer revision. Backed by four new
|
||||
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
|
||||
`/catalog/bundle` (read one bundle's roles), `/import`, and
|
||||
`/update-from-catalog` — and a new `source` column linking a role to its
|
||||
catalog slug/language/version. The catalog source is configurable via the new
|
||||
`AI_AGENT_ROLES_CATALOG_URL` env var (an `http(s)://` base URL fetches it
|
||||
remotely; otherwise a local directory; empty defaults to the in-repo
|
||||
`agent-roles-catalog/` folder — see `.env.example`). (#222)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
|
||||
page's vanity slug previously inserted a second `share_aliases` row instead of
|
||||
renaming the existing one, leaving the old `/l/<old>` link live forever and
|
||||
making the share modal's lookup nondeterministic. Slug edits and confirmed
|
||||
reassigns now rename/retarget the single row, and a new partial unique index on
|
||||
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
|
||||
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
|
||||
orphaned duplicate alias rows the old bug created (keeping the newest per
|
||||
page), so any previously-live duplicate `/l/<old>` link begins returning the
|
||||
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
|
||||
#227)
|
||||
- **Typing a custom address already used by another page no longer looks like a
|
||||
dead end.** The share modal previously flagged such a name with a red "This
|
||||
address is already in use" error, hiding the fact that saving offers to MOVE
|
||||
the address to the current page. The field now shows an informational hint —
|
||||
"This address is in use. Saving will move it to this page." — and keeps Save
|
||||
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` →
|
||||
"Move custom address?") is discoverable instead of reading as terminal. (#227)
|
||||
|
||||
## [0.94.0] - 2026-06-26
|
||||
|
||||
This release makes AI chat durable and fast: assistant turns are persisted to
|
||||
@@ -41,6 +85,16 @@ per-workspace rolling-day token budget.
|
||||
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
|
||||
alias any workspace member can reclaim. (#205)
|
||||
|
||||
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
|
||||
be marked temporary so it auto-moves to Trash once a configurable workspace
|
||||
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
|
||||
permanent first. The deadline is frozen at creation time, so later changes to
|
||||
the workspace setting never reschedule existing notes; an hourly background
|
||||
sweep trashes notes past their deadline (children ride along). An open
|
||||
temporary note shows a banner with a "Make permanent" rescue action; restoring
|
||||
a note from Trash disarms the timer so it is not immediately re-trashed.
|
||||
Operators configure the lifetime per workspace. (#201)
|
||||
|
||||
- **Persistent AI-chat history as the source of truth + server-side export.**
|
||||
An assistant turn is now persisted to the database step by step: the row is
|
||||
inserted upfront as `streaming` and updated as each agent step finishes, then
|
||||
@@ -81,9 +135,24 @@ per-workspace rolling-day token budget.
|
||||
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
|
||||
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
|
||||
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
|
||||
- **Generate a page title from its content.** A "sparkles" button in the page
|
||||
byline reads the live editor content (including unsaved edits), generates a
|
||||
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
|
||||
applies it through the existing `/pages/update` route — reflecting it in the
|
||||
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
|
||||
flag and throttled per user. (#199)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI chat now feeds the model the full stored transcript.** The per-turn model
|
||||
conversation was rebuilt from a sliding window of the 50 most recent stored
|
||||
rows, which silently dropped the beginning of any longer chat. It is now
|
||||
rebuilt from the complete non-deleted transcript in chronological order, so
|
||||
the model sees every turn (a 5000-row backstop guards process memory — a
|
||||
safety net far above any realistic chat, not a conversational limit). On a
|
||||
very long chat this can eventually reach the model's context window; the
|
||||
client already surfaces that as "start a new chat". (#202)
|
||||
|
||||
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
|
||||
For the `openai` driver the chat provider defaults to the openai-compatible
|
||||
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
|
||||
|
||||
@@ -104,6 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||
|
||||
### In progress
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||
|
||||
### В процессе
|
||||
|
||||
|
||||
149
agent-roles-catalog/README.md
Normal file
149
agent-roles-catalog/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Agent roles catalog
|
||||
|
||||
This directory is **data, not application code**. It holds the content of an
|
||||
"agent roles catalog": reusable agent role definitions (system prompts plus a
|
||||
little metadata), grouped into bundles and translated into one or more
|
||||
languages. A separate server reads these files and serves them; nothing here is
|
||||
executable application logic except the validation script.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
agent-roles-catalog/
|
||||
index.json # the catalog manifest: bundles, languages, role versions
|
||||
bundles/
|
||||
<bundle-id>/
|
||||
<lang>.json # one file per declared language (e.g. ru.json, en.json)
|
||||
scripts/
|
||||
check.mjs # validates the catalog (no dependencies)
|
||||
package.json # defines the `check` script
|
||||
README.md
|
||||
```
|
||||
|
||||
Currently shipped bundles:
|
||||
|
||||
- `editorial` — the editorial suite (structural-editor, line-editor,
|
||||
copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`.
|
||||
- `research` — a single `researcher` role, languages `ru`, `en`.
|
||||
|
||||
## How it's served
|
||||
|
||||
The server does not bundle this data; it reads it at request time from a single
|
||||
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
|
||||
(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of
|
||||
three sources:
|
||||
|
||||
- **`http(s)://…`** — a REMOTE base URL. The server fetches `<base>/index.json`
|
||||
for the manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened
|
||||
bundle file (e.g. the raw GitHub base of the catalog repo in production).
|
||||
- **any other non-empty value** — a LOCAL filesystem directory; the same
|
||||
`index.json` / `bundles/<id>/<lang>.json` paths are read from disk.
|
||||
- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder
|
||||
(this directory), i.e. local dev reads these files directly.
|
||||
|
||||
In every case the layout below is what the server expects, and the fetched JSON
|
||||
is re-validated server-side (the catalog is treated as untrusted input). See
|
||||
`.env.example` for the variable and the CHANGELOG for the rollout.
|
||||
|
||||
## `index.json` schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial", // unique bundle id; matches bundles/<id>/
|
||||
"name": { "ru": "...", "en": "..." }, // localized display name
|
||||
"description": { "ru": "...", "en": "..." },
|
||||
"languages": ["ru", "en"], // which <lang>.json files must exist
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 1 }
|
||||
// ...
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`version` lives **here, in index.json**, per role. Bump it whenever a role's
|
||||
content (instructions, name, description, etc.) changes, so consumers can detect
|
||||
updates.
|
||||
|
||||
## Bundle (`<lang>.json`) schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"language": "ru",
|
||||
"roles": [
|
||||
{
|
||||
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
|
||||
"emoji": "🧱",
|
||||
"name": "...", // REQUIRED, localized
|
||||
"description": "...", // localized
|
||||
"instructions": "...", // REQUIRED, the system prompt, localized
|
||||
"autoStart": true, // whether the role starts working immediately
|
||||
"launchMessage": "..." // first message sent on launch (or null)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `modelConfig` is intentionally absent; the server treats an absent
|
||||
`modelConfig` as `null`.
|
||||
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
|
||||
files of the same bundle. Only `name`, `description`, `instructions`, and
|
||||
`launchMessage` are translated.
|
||||
|
||||
## Slug uniqueness
|
||||
|
||||
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
|
||||
bundle. A slug appears once per language file of its bundle (same slug in
|
||||
`ru.json` and `en.json`), but no two different bundles may share a slug.
|
||||
`scripts/check.mjs` enforces this.
|
||||
|
||||
## How to add things
|
||||
|
||||
### Add a role to an existing bundle
|
||||
|
||||
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
|
||||
`slug` and `version: 1`.
|
||||
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
|
||||
bundle, translating `name`, `description`, `instructions`, and
|
||||
`launchMessage`.
|
||||
3. Run the check (see below).
|
||||
|
||||
### Add a bundle
|
||||
|
||||
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
|
||||
`languages`, `roles`).
|
||||
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
|
||||
object per `roles[]` entry.
|
||||
3. Run the check.
|
||||
|
||||
### Add a language to a bundle
|
||||
|
||||
1. Add the language code to that bundle's `languages[]` in `index.json`.
|
||||
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
|
||||
translated.
|
||||
3. Run the check.
|
||||
|
||||
### Change a role's content
|
||||
|
||||
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
|
||||
`version`** in `index.json`.
|
||||
|
||||
## Validating
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
node scripts/check.mjs # or: npm run check
|
||||
```
|
||||
|
||||
It fails (exit code 1) if any slug is duplicated across the catalog, if a
|
||||
bundle's index `roles[]` don't match the slugs present in each language file, if
|
||||
a declared language file is missing, or if any role is missing a required field
|
||||
(`slug`, `name`, `instructions`). It prints `OK` on success.
|
||||
60
agent-roles-catalog/bundles/editorial/en.json
Normal file
60
agent-roles-catalog/bundles/editorial/en.json
Normal file
File diff suppressed because one or more lines are too long
60
agent-roles-catalog/bundles/editorial/ru.json
Normal file
60
agent-roles-catalog/bundles/editorial/ru.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/en.json
Normal file
15
agent-roles-catalog/bundles/research/en.json
Normal file
File diff suppressed because one or more lines are too long
15
agent-roles-catalog/bundles/research/ru.json
Normal file
15
agent-roles-catalog/bundles/research/ru.json
Normal file
File diff suppressed because one or more lines are too long
32
agent-roles-catalog/index.json
Normal file
32
agent-roles-catalog/index.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"bundles": [
|
||||
{
|
||||
"id": "editorial",
|
||||
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
|
||||
"description": {
|
||||
"ru": "Полный цикл редактуры статьи: структура, стиль, грамматика, факты, корректура и нарратив.",
|
||||
"en": "The full article-editing cycle: structure, style, grammar, facts, proofreading, and narrative."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [
|
||||
{ "slug": "structural-editor", "version": 1 },
|
||||
{ "slug": "line-editor", "version": 1 },
|
||||
{ "slug": "copy-editor", "version": 1 },
|
||||
{ "slug": "fact-checker", "version": 1 },
|
||||
{ "slug": "proofreader", "version": 1 },
|
||||
{ "slug": "narrator", "version": 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "research",
|
||||
"name": { "ru": "Исследование", "en": "Research" },
|
||||
"description": {
|
||||
"ru": "Глубокое исследование темы с подготовкой отчёта.",
|
||||
"en": "Deep research on a topic with a prepared report."
|
||||
},
|
||||
"languages": ["ru", "en"],
|
||||
"roles": [ { "slug": "researcher", "version": 1 } ]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
agent-roles-catalog/package.json
Normal file
8
agent-roles-catalog/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "agent-roles-catalog",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "node scripts/check.mjs"
|
||||
}
|
||||
}
|
||||
130
agent-roles-catalog/scripts/check.mjs
Normal file
130
agent-roles-catalog/scripts/check.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
// Validates the agent roles catalog.
|
||||
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
|
||||
// between a bundle's index roles[] and the slugs present in each language
|
||||
// file, a missing declared language file, or a role missing required fields.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const catalogDir = join(__dirname, "..");
|
||||
|
||||
const errors = [];
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
errors.push(`Cannot read/parse ${path}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const indexPath = join(catalogDir, "index.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
console.error(`Missing index.json at ${indexPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const index = readJson(indexPath);
|
||||
if (!index) {
|
||||
for (const e of errors) console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
|
||||
if (bundles.length === 0) {
|
||||
errors.push("index.json has no bundles[]");
|
||||
}
|
||||
|
||||
// Track every slug seen across the whole catalog to detect duplicates.
|
||||
const slugSeen = new Map(); // slug -> "bundleId/lang"
|
||||
|
||||
for (const bundle of bundles) {
|
||||
const bundleId = bundle.id;
|
||||
if (!bundleId) {
|
||||
errors.push("A bundle in index.json is missing an id");
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
|
||||
// Duplicate slugs inside the bundle index roles[].
|
||||
const indexSlugSet = new Set(indexSlugs);
|
||||
if (indexSlugSet.size !== indexSlugs.length) {
|
||||
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
|
||||
}
|
||||
|
||||
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
|
||||
if (languages.length === 0) {
|
||||
errors.push(`Bundle "${bundleId}" declares no languages`);
|
||||
}
|
||||
|
||||
for (const lang of languages) {
|
||||
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
|
||||
if (!existsSync(langPath)) {
|
||||
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langFile = readJson(langPath);
|
||||
if (!langFile) continue;
|
||||
|
||||
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
|
||||
const fileSlugs = roles.map((r) => r && r.slug);
|
||||
|
||||
// (d) Required fields per role.
|
||||
for (const role of roles) {
|
||||
for (const field of ["slug", "name", "instructions"]) {
|
||||
if (role == null || role[field] == null || role[field] === "") {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) index roles[] must match the slugs present in each language file.
|
||||
const fileSlugSet = new Set(fileSlugs);
|
||||
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
|
||||
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
|
||||
if (missingInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (extraInFile.length > 0) {
|
||||
errors.push(
|
||||
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// (a) Duplicate slugs across the whole catalog.
|
||||
for (const slug of fileSlugs) {
|
||||
if (!slug) continue;
|
||||
const where = `${bundleId}/${lang}`;
|
||||
// Only flag duplicates across DIFFERENT bundles or files; the same slug
|
||||
// is expected to appear once per language file of the same bundle.
|
||||
if (slugSeen.has(slug)) {
|
||||
const prev = slugSeen.get(slug);
|
||||
const prevBundle = prev.split("/")[0];
|
||||
if (prevBundle !== bundleId) {
|
||||
errors.push(
|
||||
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
slugSeen.set(slug, where);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Catalog check FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
|
||||
"Toggle AI search": "KI-Suche umschalten",
|
||||
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
|
||||
"Toggle generative AI": "Generative KI umschalten",
|
||||
"Upgrade your plan": "Upgrade Ihres Plans",
|
||||
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
|
||||
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",
|
||||
|
||||
@@ -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",
|
||||
@@ -676,9 +687,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
|
||||
"Toggle AI search": "Toggle AI search",
|
||||
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
|
||||
"Toggle generative AI": "Toggle generative AI",
|
||||
"Upgrade your plan": "Upgrade your plan",
|
||||
"Available with a paid license": "Available with a paid license",
|
||||
"Upgrade your license tier.": "Upgrade your license tier.",
|
||||
@@ -1325,10 +1333,36 @@
|
||||
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
|
||||
"This address is already in use": "This address is already in use",
|
||||
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
|
||||
"Move custom address?": "Move custom address?",
|
||||
"Move here": "Move here",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
|
||||
"Failed to set custom address": "Failed to set custom address",
|
||||
"Failed to remove custom address": "Failed to remove custom address"
|
||||
"Failed to remove custom address": "Failed to remove custom address",
|
||||
"Generate title with AI": "Generate title with AI",
|
||||
"Title generated": "Title generated",
|
||||
"Failed to generate title": "Failed to generate title",
|
||||
"The note is empty": "The note is empty",
|
||||
"Could not generate a title": "Could not generate a title",
|
||||
"AI title generation is disabled": "AI title generation is disabled",
|
||||
"AI is not configured": "AI is not configured",
|
||||
"Too many requests, please try again later": "Too many requests, please try again later",
|
||||
"Import from catalog": "Import from catalog",
|
||||
"Browse the catalog": "Browse the catalog",
|
||||
"Role catalog": "Role catalog",
|
||||
"On name conflict": "On name conflict",
|
||||
"Skip": "Skip",
|
||||
"Import": "Import",
|
||||
"Installed": "Installed",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
"This language is no longer available in the catalog": "This language is no longer available in the catalog"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
|
||||
"Toggle AI search": "Alternar búsqueda de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
|
||||
"Toggle generative AI": "Activar IA generativa",
|
||||
"Upgrade your plan": "Mejora tu plan",
|
||||
"Available with a paid license": "Disponible con una licencia de pago",
|
||||
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
|
||||
"Toggle AI search": "Basculer la recherche IA",
|
||||
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
|
||||
"Toggle generative AI": "Activer/désactiver l'IA générative",
|
||||
"Upgrade your plan": "Mettez à niveau votre forfait",
|
||||
"Available with a paid license": "Disponible avec une licence payante",
|
||||
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
|
||||
"Toggle AI search": "Attiva/disattiva ricerca AI",
|
||||
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
|
||||
"Toggle generative AI": "Attiva/Disattiva AI generativa",
|
||||
"Upgrade your plan": "Aggiorna il tuo piano",
|
||||
"Available with a paid license": "Disponibile con una licenza a pagamento",
|
||||
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
|
||||
"Toggle AI search": "AI検索を切り替え",
|
||||
"Generative AI (Ask AI)": "生成AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
|
||||
"Toggle generative AI": "生成AIを切り替える",
|
||||
"Upgrade your plan": "プランをアップグレードする",
|
||||
"Available with a paid license": "有料ライセンスで利用可能",
|
||||
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
|
||||
"Toggle AI search": "AI 검색 전환",
|
||||
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
|
||||
"Toggle generative AI": "생성 AI 토글",
|
||||
"Upgrade your plan": "요금제를 업그레이드하세요",
|
||||
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
|
||||
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
|
||||
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
|
||||
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
|
||||
"Toggle generative AI": "Generatieve AI schakelen",
|
||||
"Upgrade your plan": "Upgrade je abonnement",
|
||||
"Available with a paid license": "Beschikbaar met een betaalde licentie",
|
||||
"Upgrade your license tier.": "Upgrade je licentieniveau.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
|
||||
"Toggle AI search": "Alternar pesquisa de IA",
|
||||
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
|
||||
"Toggle generative AI": "Alternar IA generativa",
|
||||
"Upgrade your plan": "Faça upgrade do seu plano",
|
||||
"Available with a paid license": "Disponível com uma licença paga",
|
||||
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",
|
||||
|
||||
@@ -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": "Удалить навсегда",
|
||||
@@ -738,9 +749,6 @@
|
||||
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
|
||||
"Toggle AI search": "Переключить поиск ИИ",
|
||||
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
|
||||
"Toggle generative AI": "Переключить генеративный ИИ",
|
||||
"Upgrade your plan": "Обновите свой тарифный план",
|
||||
"Available with a paid license": "Доступно с платной лицензией",
|
||||
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
|
||||
@@ -1182,10 +1190,37 @@
|
||||
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
|
||||
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
|
||||
"This address is already in use": "Этот адрес уже занят",
|
||||
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
|
||||
"Move custom address?": "Переместить пользовательский адрес?",
|
||||
"Move here": "Переместить сюда",
|
||||
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
|
||||
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
|
||||
"Failed to set custom address": "Не удалось задать пользовательский адрес",
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
|
||||
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
|
||||
"Generate title with AI": "Сгенерировать название через AI",
|
||||
"Title generated": "Название сгенерировано",
|
||||
"Failed to generate title": "Не удалось сгенерировать название",
|
||||
"The note is empty": "Заметка пустая",
|
||||
"Could not generate a title": "Не удалось придумать название",
|
||||
"AI title generation is disabled": "Генерация названий через AI отключена",
|
||||
"AI is not configured": "AI не настроен",
|
||||
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
|
||||
"Import from catalog": "Импорт из каталога",
|
||||
"Browse the catalog": "Открыть каталог",
|
||||
"Role catalog": "Каталог ролей",
|
||||
"On name conflict": "При конфликте имён",
|
||||
"Skip": "Пропустить",
|
||||
"Import": "Импортировать",
|
||||
"Installed": "Установлено",
|
||||
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
|
||||
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге"
|
||||
}
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
|
||||
"Toggle AI search": "Переключити пошук з ШІ",
|
||||
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
|
||||
"Toggle generative AI": "Переключити генеративний ШІ",
|
||||
"Upgrade your plan": "Оновіть свій тарифний план",
|
||||
"Available with a paid license": "Доступно за платною ліцензією",
|
||||
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",
|
||||
|
||||
@@ -665,9 +665,6 @@
|
||||
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
|
||||
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
|
||||
"Toggle AI search": "切换AI搜索",
|
||||
"Generative AI (Ask AI)": "生成型AI (询问AI)",
|
||||
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
|
||||
"Toggle generative AI": "切换生成型AI",
|
||||
"Upgrade your plan": "升级您的方案",
|
||||
"Available with a paid license": "需付费许可才可用",
|
||||
"Upgrade your license tier.": "升级您的许可等级。",
|
||||
|
||||
@@ -26,16 +26,20 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
});
|
||||
|
||||
import MessageItem from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
|
||||
// as the memo key. The signature must NOT be recomputed inside the memo from the
|
||||
// live (mutable) message — see message-item.tsx.
|
||||
const renderRow = (message: UIMessage) =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} />
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
@@ -67,7 +71,7 @@ describe("MessageItem markdown memoization", () => {
|
||||
]);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={next} />
|
||||
<MessageItem message={next} signature={messageSignature(next)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
@@ -78,4 +82,35 @@ describe("MessageItem markdown memoization", () => {
|
||||
expect(callsFor("beta")).toBe(1);
|
||||
expect(callsFor("gamm")).toBe(1);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
|
||||
// `parts` IN PLACE and reusing the message object. A row that mounted empty
|
||||
// (reasoning-first providers render nothing at first) must still stream its text
|
||||
// in once the parent hands down a fresh signature snapshot. Before the fix the
|
||||
// memo recomputed the signature from the (mutated) message — identical on both
|
||||
// sides — and froze the row at its empty render, so the answer never appeared.
|
||||
it("streams text in after the row mounted empty and parts mutated in place", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does).
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Empty text part: nothing visible rendered yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageItem message={message} signature={messageSignature(message)} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders (the memo did NOT freeze the empty mount).
|
||||
expect(callsFor("streamed answer")).toBe(1);
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,21 +10,28 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
import { arePropsEqual } from "./message-item";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
|
||||
/**
|
||||
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
|
||||
* return false on any visible prop/content change (so the row re-renders) and
|
||||
* true when nothing visible changed (so a finalized row is skipped). A FIXED
|
||||
* message id is used so a content-identical clone yields an equal signature.
|
||||
* true when nothing visible changed (so a finalized row is skipped). The memo key
|
||||
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
|
||||
* per render via `messageSignature(message)`. A FIXED message id is used so a
|
||||
* content-identical clone yields an equal signature.
|
||||
*/
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
// Build the props the parent would pass, INCLUDING the snapshot signature it
|
||||
// computes during its own render (the load-bearing part — see message-item.tsx:
|
||||
// the signature must never be recomputed inside arePropsEqual).
|
||||
const props = (
|
||||
message: UIMessage,
|
||||
over: Record<string, unknown> = {},
|
||||
) => ({
|
||||
message,
|
||||
signature: messageSignature(message),
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
@@ -53,7 +60,7 @@ describe("arePropsEqual", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on the identity fast path (same message object, equal props)", () => {
|
||||
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
|
||||
const m = msg([{ type: "text", text: "answer" }]);
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
@@ -70,4 +77,36 @@ describe("arePropsEqual", () => {
|
||||
const b = msg([{ type: "text", text: "answer grown" }]);
|
||||
expect(arePropsEqual(props(a), props(b))).toBe(false);
|
||||
});
|
||||
|
||||
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
|
||||
// `parts` in place and handing back a message wrapper that SHARES them. So the
|
||||
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
|
||||
// recomputing `messageSignature(message)` inside the comparator would read
|
||||
// identical (latest) content on BOTH sides → always "equal" → the memo skips
|
||||
// every streamed update and the assistant row freezes at its initial empty
|
||||
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
|
||||
// the parent captured at each render. This fails against the old implementation
|
||||
// (a `prev.message === next.message` fast path + a signature recomputed from the
|
||||
// live objects).
|
||||
it("re-renders when parts were mutated in place but the snapshot changed", () => {
|
||||
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
|
||||
const prevSig = messageSignature(message); // snapshot BEFORE the delta
|
||||
// SDK streams a delta by mutating the shared part IN PLACE:
|
||||
(message.parts[0] as { text: string }).text = "hello world";
|
||||
const nextSig = messageSignature(message); // snapshot AFTER the delta
|
||||
expect(prevSig).not.toBe(nextSig);
|
||||
// Same object reference on both sides (the SDK reuses it), differing snapshots.
|
||||
const base = {
|
||||
message,
|
||||
showCitations: true,
|
||||
neutralizeInternalLinks: false,
|
||||
assistantName: "AI",
|
||||
};
|
||||
expect(
|
||||
arePropsEqual(
|
||||
{ ...base, signature: prevSig },
|
||||
{ ...base, signature: nextSig },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,30 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
/**
|
||||
* Immutable content signature for `message`, computed by the PARENT
|
||||
* (MessageList) during its render via `messageSignature(message)`. This is the
|
||||
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
|
||||
* NOT recomputed from `message` inside `arePropsEqual`.
|
||||
*
|
||||
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
|
||||
* array/objects in place and handing back a message wrapper that SHARES those
|
||||
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
|
||||
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
|
||||
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
|
||||
* post-mount render, and the assistant row freezes at its initial empty (null)
|
||||
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
|
||||
* providers start empty, so NOTHING shows). Snapshotting the signature into this
|
||||
* immutable string prop in the parent fixes that: `prev.signature` holds the
|
||||
* value from the previous render (old content) and `next.signature` the new
|
||||
* content, so they differ as the turn streams in and the row re-renders.
|
||||
*/
|
||||
signature: string;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render page citation links.
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
@@ -88,6 +106,8 @@ function MessageItem({
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
const { t } = useTranslation();
|
||||
const isUser = message.role === "user";
|
||||
|
||||
@@ -203,24 +223,30 @@ function MessageItem({
|
||||
}
|
||||
|
||||
/** Skip re-rendering a message whose visible content is unchanged. The streaming
|
||||
* TAIL message gets a fresh object whose signature changes each delta, so it
|
||||
* still re-renders and streams in; every FINALIZED message is skipped, turning a
|
||||
* per-token whole-transcript re-render into a tail-only one. */
|
||||
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
|
||||
* parent), so it still re-renders and streams in; every FINALIZED message keeps
|
||||
* the same signature and is skipped, turning a per-token whole-transcript
|
||||
* re-render into a tail-only one.
|
||||
*
|
||||
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
|
||||
* at its own render), NEVER `messageSignature(prev.message)` vs
|
||||
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
|
||||
* place, so both `prev.message` and `next.message` reflect the latest content
|
||||
* here — recomputing the signature from them yields equal strings every time and
|
||||
* freezes the row at its initial empty render (the bug this guards against). See
|
||||
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
|
||||
* fast path: same-reference-but-mutated must still re-render when the snapshot
|
||||
* signature changed. */
|
||||
export function arePropsEqual(
|
||||
prev: MessageItemProps,
|
||||
next: MessageItemProps,
|
||||
): boolean {
|
||||
if (
|
||||
prev.showCitations !== next.showCitations ||
|
||||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
|
||||
prev.assistantName !== next.assistantName
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Fast path: identical message object (finalized rows keep their identity
|
||||
// across deltas) — skip without building signatures.
|
||||
if (prev.message === next.message) return true;
|
||||
return messageSignature(prev.message) === messageSignature(next.message);
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageItem, arePropsEqual);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
|
||||
// Mirrors the t-mock pattern used by the other component tests in this folder
|
||||
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
|
||||
// every OTHER named export of markdown.ts intact via `importActual`, and override
|
||||
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
|
||||
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
|
||||
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
|
||||
// factory runs.
|
||||
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
|
||||
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/features/ai-chat/utils/markdown.ts")
|
||||
>("@/features/ai-chat/utils/markdown.ts");
|
||||
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
|
||||
});
|
||||
|
||||
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
|
||||
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
|
||||
// whole point of this file (it closes the parent-side coverage gap left by the
|
||||
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
|
||||
// harness). Use the relative import for the component under test, mirroring how
|
||||
// message-list.tsx itself imports `MessageItem from "./message-item"`.
|
||||
import MessageList from "./message-list";
|
||||
|
||||
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
|
||||
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
|
||||
//
|
||||
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
|
||||
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||
// regression test to model how the AI SDK hands back the SAME message object.
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
|
||||
describe("MessageList", () => {
|
||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const { queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList
|
||||
messages={[msg([{ type: "text", text: "hello world" }])]}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
// The assistant text renders, which proves MessageList mounted the real
|
||||
// MessageItem and handed it a valid `signature` prop (computed from the real
|
||||
// `messageSignature`) — the full parent -> child -> markdown path is live.
|
||||
expect(queryByText("hello world")).not.toBeNull();
|
||||
});
|
||||
|
||||
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
|
||||
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
|
||||
// delta that REUSES the same message object. The fix moved the content signature
|
||||
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
|
||||
// every render and forward it as the immutable `signature` prop, so MessageItem's
|
||||
// memo (which compares that prop snapshot) sees it change and re-renders the row.
|
||||
//
|
||||
// This test exercises the PARENT half that the memo tests only simulate: if
|
||||
// MessageList ever cached/memoized the signature keyed on the message object's
|
||||
// identity (which stays stable across deltas while its `parts` mutate in place),
|
||||
// the snapshot would never change, MessageItem's memo would skip every delta, and
|
||||
// the row would freeze at its empty mount — exactly the regression class. That
|
||||
// would make this test fail. See message-item.tsx (`signature` prop +
|
||||
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
|
||||
// snapshot at render time).
|
||||
it("reflects in-place part mutation of a reused message object across renders", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
// Reuse ONE message object across renders (as the SDK does). The empty text
|
||||
// part means MessageItem renders nothing visible initially.
|
||||
const message = msg([{ type: "text", text: "" }]);
|
||||
const { rerender, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Nothing streamed yet.
|
||||
expect(queryByText("streamed answer")).toBeNull();
|
||||
|
||||
// SDK delta: mutate the SAME part in place on the SAME message object...
|
||||
(message.parts[0] as { text: string }).text = "streamed answer";
|
||||
// ...then re-render with a NEW array literal that still holds the SAME mutated
|
||||
// message object (this mirrors useChat handing back a fresh array of reused
|
||||
// message objects on each delta).
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The grown text now renders: MessageList re-snapshotted the signature, so the
|
||||
// row re-rendered instead of freezing at its empty mount.
|
||||
expect(queryByText("streamed answer")).not.toBeNull();
|
||||
expect(
|
||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -196,9 +197,16 @@ export default function MessageList({
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
// shared `parts` in place, so prev/next message objects both read the
|
||||
// latest content there and the memo would skip every streamed update
|
||||
// (freezing the row at its empty render). See message-item.tsx.
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
|
||||
@@ -13,21 +13,40 @@ import {
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
getAiRoles,
|
||||
importAiRolesFromCatalog,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
updateAiRoleFromCatalog,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
// Catalog reads resolve bundle names per language, so the language is part of
|
||||
// the cache key (a language switch refetches rather than reusing stale names).
|
||||
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
||||
"ai-role-catalog",
|
||||
language,
|
||||
];
|
||||
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
||||
bundleId: string,
|
||||
language: string,
|
||||
) => ["ai-role-catalog-bundle", bundleId, language];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -223,3 +242,109 @@ export function useDeleteAiRoleMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
|
||||
* fetch runs only when the catalog modal is open. The catalog can 502 when the
|
||||
* curated source is unreachable; callers handle the error state in the UI.
|
||||
*/
|
||||
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
|
||||
return useQuery<IAiRoleCatalog, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
|
||||
queryFn: () => getAiRoleCatalog(language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
|
||||
* fetch only runs when a bundle is actually expanded.
|
||||
*/
|
||||
export function useAiRoleCatalogBundleQuery(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiRoleCatalogBundle, Error>({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Failed to import {{count}} role(s)", {
|
||||
count: result.errors.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// Imported roles can appear in the chat picker / badges.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
|
||||
mutationFn: (id) => updateAiRoleFromCatalog(id),
|
||||
onSuccess: (result) => {
|
||||
// The server returns updated:false with a reason for a no-op (already
|
||||
// up to date / removed from catalog / language no longer offered). Map
|
||||
// each reason to a specific message instead of a generic "up to date".
|
||||
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
||||
// boolean discriminant does not narrow under this project's
|
||||
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
||||
// union, so the comparisons below are compiler-checked.
|
||||
let message: string;
|
||||
if (!("reason" in result)) {
|
||||
message = t("Updated to the latest version");
|
||||
} else if (result.reason === "not-in-catalog") {
|
||||
message = t("This role is no longer in the catalog");
|
||||
} else if (result.reason === "language-unavailable") {
|
||||
message = t("This language is no longer available in the catalog");
|
||||
} else {
|
||||
// "up-to-date" (the only remaining reason).
|
||||
message = t("Already up to date");
|
||||
}
|
||||
notifications.show({ message });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
|
||||
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
|
||||
// the result carries partial errors. These tests pin both branches via
|
||||
// renderHook with a mocked service (twin precedent:
|
||||
// update-from-catalog-message.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key with interpolated values so we assert against the exact
|
||||
// English message strings (mirrors react-i18next's default interpolation).
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars
|
||||
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleImportResult) {
|
||||
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useImportAiRolesFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate({
|
||||
bundleId: "general",
|
||||
language: "en",
|
||||
conflict: "rename",
|
||||
});
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
});
|
||||
});
|
||||
|
||||
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
|
||||
await runMutation({
|
||||
created: 1,
|
||||
renamed: 0,
|
||||
skipped: 0,
|
||||
errors: [
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
message: "Imported 1, renamed 0, skipped 0",
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
|
||||
color: "red",
|
||||
message: "Failed to import 2 role(s)",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
||||
// a user-facing notification message. These tests pin each of the four branches
|
||||
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
||||
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
// `t` echoes the key so we assert against the exact English message strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
}));
|
||||
|
||||
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
||||
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
||||
const { result: hook } = renderHook(
|
||||
() => useUpdateAiRoleFromCatalogMutation(),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
hook.current.mutate("role-1");
|
||||
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
||||
}
|
||||
|
||||
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("updated:true -> 'Updated to the latest version'", async () => {
|
||||
await runMutation({
|
||||
updated: true,
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
role: { id: "role-1" } as never,
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Updated to the latest version",
|
||||
});
|
||||
});
|
||||
|
||||
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "not-in-catalog" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This role is no longer in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
||||
await runMutation({ updated: false, reason: "language-unavailable" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "This language is no longer available in the catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("up-to-date -> 'Already up to date'", async () => {
|
||||
await runMutation({ updated: false, reason: "up-to-date" });
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Already up to date",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,13 @@ import {
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
IAiRoleCreate,
|
||||
IAiRoleImportPayload,
|
||||
IAiRoleImportResult,
|
||||
IAiRoleUpdate,
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -68,6 +73,19 @@ export async function exportAiChat(
|
||||
return req.data.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from note content (markdown). One-shot, non-streaming
|
||||
* (#199): the server only summarizes the supplied text and returns a suggestion;
|
||||
* it never writes the page. The caller applies the title via /pages/update.
|
||||
*/
|
||||
export async function generatePageTitle(content: string): Promise<string> {
|
||||
const req = await api.post<{ title: string }>(
|
||||
"/ai-chat/generate-page-title",
|
||||
{ content },
|
||||
);
|
||||
return req.data.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
@@ -99,3 +117,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
|
||||
* Browse a curated catalog, import roles/bundles into the workspace, and update
|
||||
* an imported role when the catalog ships a newer version. Same `{ data }`
|
||||
* unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** Browse the catalog, optionally localized to `language`. */
|
||||
export async function getAiRoleCatalog(
|
||||
language?: string,
|
||||
): Promise<IAiRoleCatalog> {
|
||||
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
|
||||
language,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a language (role content + versions). */
|
||||
export async function getAiRoleCatalogBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<IAiRoleCatalogBundle> {
|
||||
const req = await api.post<IAiRoleCatalogBundle>(
|
||||
"/ai-chat/roles/catalog/bundle",
|
||||
{ bundleId, language },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace (admin). */
|
||||
export async function importAiRolesFromCatalog(
|
||||
payload: IAiRoleImportPayload,
|
||||
): Promise<IAiRoleImportResult> {
|
||||
const req = await api.post<IAiRoleImportResult>(
|
||||
"/ai-chat/roles/import",
|
||||
payload,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source (admin). */
|
||||
export async function updateAiRoleFromCatalog(
|
||||
id: string,
|
||||
): Promise<IAiRoleUpdateFromCatalogResult> {
|
||||
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
|
||||
"/ai-chat/roles/update-from-catalog",
|
||||
{ id },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,79 @@ export interface IAiRole {
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one.
|
||||
// Admin-only (present only in the admin list view); the picker view omits it.
|
||||
// The admin UI compares `version` against the catalog to offer an update.
|
||||
source?: { slug: string; language: string; version: number } | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
|
||||
export interface IAiRoleCatalogBundleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** The browsable catalog index (mirrors `getCatalog()`). */
|
||||
export interface IAiRoleCatalog {
|
||||
languages: string[];
|
||||
bundles: IAiRoleCatalogBundleSummary[];
|
||||
}
|
||||
|
||||
/** A single role inside an opened catalog bundle (localized content + version). */
|
||||
export interface IAiRoleCatalogRole {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
|
||||
export interface IAiRoleCatalogBundle {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: IAiRoleCatalogRole[];
|
||||
}
|
||||
|
||||
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
|
||||
export interface IAiRoleImportPayload {
|
||||
bundleId: string;
|
||||
language: string;
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
slugs?: string[];
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
|
||||
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
|
||||
* to a specific message; a successful update carries the version bump + new role.
|
||||
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
|
||||
* comparisons be compiler-checked.
|
||||
*/
|
||||
export type IAiRoleUpdateFromCatalogResult =
|
||||
| {
|
||||
updated: false;
|
||||
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
|
||||
}
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
|
||||
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Build a workspace role with a catalog source. Fields irrelevant to the
|
||||
// install-state decision are filled with harmless defaults.
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const catalogRole = { slug: "writer", version: 3 };
|
||||
|
||||
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
|
||||
// pure function so the import/installed/update decision is testable directly.
|
||||
describe("catalogRoleInstallState", () => {
|
||||
it("no matching installed role -> import", () => {
|
||||
const result = catalogRoleInstallState(catalogRole, [], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version > catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version == catalog -> installed", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "installed", installed });
|
||||
});
|
||||
|
||||
it("same slug + language, installed version < catalog -> update (from/to)", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("same slug but DIFFERENT language -> import (a separate install)", () => {
|
||||
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
|
||||
// fresh import, not treat the ru copy as already installed.
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "ru",
|
||||
version: 5,
|
||||
});
|
||||
const result = catalogRoleInstallState(catalogRole, [installed], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
|
||||
it("matches the right language when the same slug is installed in several", () => {
|
||||
const ru = installedRole(
|
||||
{ slug: "writer", language: "ru", version: 5 },
|
||||
{ id: "ru-role" },
|
||||
);
|
||||
const en = installedRole(
|
||||
{ slug: "writer", language: "en", version: 1 },
|
||||
{ id: "en-role" },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
|
||||
expect(result).toEqual({
|
||||
state: "update",
|
||||
installed: en,
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source) sharing the name", () => {
|
||||
const manual = installedRole(
|
||||
{ slug: "writer", language: "en", version: 9 },
|
||||
{ source: null },
|
||||
);
|
||||
const result = catalogRoleInstallState(catalogRole, [manual], "en");
|
||||
expect(result).toEqual({ state: "import" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* The install state of a single catalog role relative to the workspace's
|
||||
* existing roles. Extracted as a pure function so the catalog modal's role-state
|
||||
* computation is unit-testable without mounting the component (mirrors the
|
||||
* `roleLaunchMessage` precedent in role-launch.ts).
|
||||
*
|
||||
* A catalog role is matched to an installed role by BOTH `source.slug` and
|
||||
* `source.language`: the same slug in a different language is a separate install
|
||||
* (so it shows as "import", not "installed"). When matched, the installed source
|
||||
* version decides the state:
|
||||
* - no match -> "import"
|
||||
* - matched & installed version >= catalog version -> "installed"
|
||||
* - matched & installed version < catalog version -> "update" (from -> to)
|
||||
*/
|
||||
export type CatalogRoleInstallState =
|
||||
| { state: "import" }
|
||||
| { state: "installed"; installed: IAiRole }
|
||||
| {
|
||||
state: "update";
|
||||
installed: IAiRole;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
};
|
||||
|
||||
export function catalogRoleInstallState(
|
||||
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogRoleInstallState {
|
||||
const installed = workspaceRoles.find(
|
||||
(r) => r.source?.slug === role.slug && r.source?.language === language,
|
||||
);
|
||||
if (!installed) return { state: "import" };
|
||||
const fromVersion = installed.source?.version ?? 0;
|
||||
if (fromVersion >= role.version) {
|
||||
return { state: "installed", installed };
|
||||
}
|
||||
return {
|
||||
state: "update",
|
||||
installed,
|
||||
fromVersion,
|
||||
toVersion: role.version,
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
@@ -26,8 +25,8 @@ import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -44,16 +43,12 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { templateMode = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
const user = useAtomValue(userAtom);
|
||||
const editorToolbarEnabled =
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
||||
const showLinkMenuRef = useRef(showLinkMenu);
|
||||
|
||||
@@ -61,10 +56,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
showAiMenuRef.current = showAiMenu;
|
||||
}, [showAiMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
showLinkMenuRef.current = showLinkMenu;
|
||||
}, [showLinkMenu]);
|
||||
@@ -145,7 +136,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
empty ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
showAiMenuRef.current ||
|
||||
showLinkMenuRef.current ||
|
||||
showCommentPopupRef?.current
|
||||
) {
|
||||
@@ -168,8 +158,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
if (showAiMenu || showLinkMenu) return;
|
||||
// Hide the bubble menu immediately when the link menu is shown
|
||||
if (showLinkMenu) return;
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
@@ -177,22 +167,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
style={{ zIndex: 199, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
{isGenerativeAiEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
className={clsx(classes.buttonRoot)}
|
||||
radius="0"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => {
|
||||
setShowAiMenu(true);
|
||||
}}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)}
|
||||
{!editorToolbarEnabled && (
|
||||
<>
|
||||
<NodeSelector
|
||||
|
||||
@@ -12,8 +12,6 @@ import { MediaGroup } from "./groups/media-group";
|
||||
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||
import { MoreInsertsGroup } from "./groups/more-inserts-group";
|
||||
import { HistoryGroup } from "./groups/history-group";
|
||||
import { AskAiGroup } from "./groups/ask-ai-group";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
type FixedToolbarProps = {
|
||||
@@ -28,8 +26,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||
const editor = editorProp ?? editorFromAtom;
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
|
||||
if (!editor || !state) return null;
|
||||
|
||||
@@ -43,12 +39,6 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={classes.inner}>
|
||||
{/* {isGenerativeAiEnabled && (
|
||||
<>
|
||||
<AskAiGroup />
|
||||
<div className={classes.divider} />
|
||||
</>
|
||||
)} */}
|
||||
<BlockTypeGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<InlineMarksGroup editor={editor} state={state} />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export const AskAiGroup: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const setShowAiMenu = useSetAtom(showAiMenuAtom);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="xs"
|
||||
leftSection={<IconSparkles size={14} />}
|
||||
onClick={() => setShowAiMenu(true)}
|
||||
>
|
||||
{t("Ask AI")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
color?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI "generate title" button (#199). Reads the live editor content and applies a
|
||||
* model-suggested title immediately. Rendered in the page byline, only in edit
|
||||
* mode and when the workspace's AI chat flag is on.
|
||||
*/
|
||||
export const GenerateTitleGroup: FC<Props> = ({
|
||||
pageId,
|
||||
color = "gray",
|
||||
iconSize = 20,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const gen = useGeneratePageTitle(pageId);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={color}
|
||||
aria-label={t("Generate title with AI")}
|
||||
loading={gen.isPending}
|
||||
onClick={() => gen.mutate()}
|
||||
>
|
||||
<IconSparkles size={iconSize} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -26,17 +26,20 @@ 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,
|
||||
pageEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
|
||||
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;
|
||||
@@ -74,6 +77,9 @@ export function FullEditor({
|
||||
const [user] = useAtom(userAtom);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
@@ -103,6 +109,7 @@ export function FullEditor({
|
||||
<MemoizedFixedToolbar />
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
@@ -111,11 +118,13 @@ export function FullEditor({
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
@@ -128,19 +137,23 @@ export function FullEditor({
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
pageId: string;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
editable?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDictationEnabled?: boolean;
|
||||
isTitleGenEnabled?: boolean;
|
||||
};
|
||||
|
||||
function PageByline({
|
||||
pageId,
|
||||
creator,
|
||||
contributors,
|
||||
editable,
|
||||
isEditMode,
|
||||
isDictationEnabled,
|
||||
isTitleGenEnabled,
|
||||
}: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
@@ -148,6 +161,9 @@ function PageByline({
|
||||
const showDictation = Boolean(
|
||||
isDictationEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
const showTitleGen = Boolean(
|
||||
isTitleGenEnabled && editable && isEditMode && editor,
|
||||
);
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -238,6 +254,11 @@ function PageByline({
|
||||
{showDictation && editor && (
|
||||
<DictationGroup editor={editor} color="gray" iconSize={20} />
|
||||
)}
|
||||
{/* Shown only in edit mode when the workspace's AI chat flag is on,
|
||||
so AI title generation stays reachable from the byline (#199). */}
|
||||
{showTitleGen && (
|
||||
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// --- Mocks for the hook's collaborators ---------------------------------------
|
||||
|
||||
const generatePageTitleMock = vi.fn();
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
generatePageTitle: (content: string) => generatePageTitleMock(content),
|
||||
}));
|
||||
|
||||
const updateTitleMock = vi.fn();
|
||||
const updatePageDataMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
|
||||
updatePageData: (page: unknown) => updatePageDataMock(page),
|
||||
}));
|
||||
|
||||
const emitMock = vi.fn();
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => emitMock,
|
||||
}));
|
||||
|
||||
const localEmitMock = vi.fn();
|
||||
vi.mock("@/lib/local-emitter.ts", () => ({
|
||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||
}));
|
||||
|
||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
||||
// purely via the fake page editor's getHTML().
|
||||
vi.mock("@docmost/editor-ext", () => ({
|
||||
htmlToMarkdown: (html: string) => html,
|
||||
}));
|
||||
|
||||
const notificationsShowMock = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Import after mocks are registered.
|
||||
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
||||
|
||||
// --- Test helpers -------------------------------------------------------------
|
||||
|
||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
getHTML: () => html,
|
||||
storage: { pageId },
|
||||
} as unknown as Editor;
|
||||
}
|
||||
|
||||
function makeTitleEditor(): Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
isDestroyed: false,
|
||||
isFocused: false,
|
||||
commands: { setContent: vi.fn() },
|
||||
} as unknown as Editor & {
|
||||
commands: { setContent: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
}
|
||||
|
||||
function setup(pageId: string, store = createStore()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
|
||||
wrapper,
|
||||
});
|
||||
return { result, store };
|
||||
}
|
||||
|
||||
const PAGE_A = {
|
||||
id: "pageA",
|
||||
title: "Generated Title",
|
||||
spaceId: "space1",
|
||||
slugId: "slugA",
|
||||
parentPageId: null,
|
||||
icon: null,
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useGeneratePageTitle", () => {
|
||||
it("shows a notice and bails when the editor content is empty", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
|
||||
);
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the title untouched when the model returns nothing usable", async () => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockResolvedValue(" ");
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Could not generate a title",
|
||||
color: "yellow",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
generatePageTitleMock.mockResolvedValue("Generated Title");
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Title generated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Control when generation resolves so we can navigate mid-flight.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// User navigates to page B: the live page editor now belongs to pageB.
|
||||
act(() => {
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageB"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// DB write is still correct (keyed by the captured pageId)...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bails before calling the model when the page editor is destroyed", async () => {
|
||||
const store = createStore();
|
||||
const pageEditor = makePageEditor("pageA");
|
||||
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
|
||||
store.set(pageEditorAtom as never, pageEditor);
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync();
|
||||
});
|
||||
|
||||
expect(generatePageTitleMock).not.toHaveBeenCalled();
|
||||
expect(updateTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[403, "AI title generation is disabled"],
|
||||
[503, "AI is not configured"],
|
||||
[429, "Too many requests, please try again later"],
|
||||
[500, "Failed to generate title"],
|
||||
])("maps HTTP %s onError to a friendly message", async (status, message) => {
|
||||
const store = createStore();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, makeTitleEditor());
|
||||
generatePageTitleMock.mockRejectedValue({ response: { status } });
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message, color: "red" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
134
apps/client/src/features/editor/hooks/use-generate-page-title.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
// Maximum length we send to the model. The server truncates again; this is a
|
||||
// cheap client-side bound so we never ship a huge body over the wire.
|
||||
const MAX_CONTENT_CHARS = 20000;
|
||||
|
||||
/**
|
||||
* Generate a title for the given page from the LIVE editor content (#199),
|
||||
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
|
||||
* server endpoint only summarizes the supplied markdown — it never writes the
|
||||
* page; the actual title write goes through the existing /pages/update mutation
|
||||
* (which enforces edit permission), and is mirrored to the title field + other
|
||||
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
|
||||
* button can show a loading state via `isPending`.
|
||||
*/
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
|
||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
||||
if (!markdown) {
|
||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (
|
||||
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
|
||||
).trim();
|
||||
if (!title) {
|
||||
// The model returned nothing usable — keep the existing title untouched.
|
||||
notifications.show({
|
||||
message: t("Could not generate a title"),
|
||||
color: "yellow",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
|
||||
notifications.show({ message: t("Title generated") });
|
||||
},
|
||||
onError: (err) => {
|
||||
// Map known HTTP statuses to friendly messages, falling back to generic.
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
const message =
|
||||
status === 403
|
||||
? t("AI title generation is disabled")
|
||||
: status === 503
|
||||
? t("AI is not configured")
|
||||
: status === 429
|
||||
? t("Too many requests, please try again later")
|
||||
: t("Failed to generate title");
|
||||
notifications.show({ message, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Menu, Text } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Button, Menu, Stack, Text } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -10,24 +11,38 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { canCreatePage } from "./can-create-page.ts";
|
||||
|
||||
// Prominent home-screen action to create a new note (page). Because the home
|
||||
// screen has no active space, the target space is resolved from the user's
|
||||
// writable spaces: created directly when there is one, picked from a dropdown
|
||||
// when there are several.
|
||||
export default function NewNoteButton() {
|
||||
// A single create-note action, parametrized by `temporary`. Self-contained: it
|
||||
// owns its own create mutation so the regular and temporary buttons show
|
||||
// independent loading state, while the list of writable spaces is resolved once
|
||||
// by the parent and passed in. With exactly one writable space it creates
|
||||
// directly; with several it shows a target-space picker.
|
||||
function CreateNoteButton({
|
||||
writableSpaces,
|
||||
temporary,
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
}: {
|
||||
writableSpaces: ISpace[];
|
||||
temporary: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
// Mantine color token; lets the temporary action tint toward the warm
|
||||
// orange/amber used by the clock marker + banner while "New note" stays neutral.
|
||||
color: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
try {
|
||||
// `spaceId` is accepted by the create-page endpoint but is not part of
|
||||
// the shared `IPageInput` type; cast to satisfy the mutation signature.
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
@@ -35,24 +50,21 @@ export default function NewNoteButton() {
|
||||
}
|
||||
};
|
||||
|
||||
// No writable space → nothing to create in; render nothing.
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
const isPending = createPageMutation.isPending;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
return (
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
onClick={() => createNote(writableSpaces[0])}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -62,14 +74,14 @@ export default function NewNoteButton() {
|
||||
<Menu shadow="md" width="target" position="bottom-start">
|
||||
<Menu.Target>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color={color}
|
||||
fullWidth
|
||||
leftSection={icon}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("New note")}
|
||||
{label}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
@@ -99,3 +111,39 @@ export default function NewNoteButton() {
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// Prominent home-screen actions to create a new note (page). Because the home
|
||||
// screen has no active space, the target space is resolved from the user's
|
||||
// writable spaces: created directly when there is one, picked from a dropdown
|
||||
// when there are several. Renders two full-width, vertically stacked buttons: a
|
||||
// neutral regular note and an orange-tinted temporary note (which auto-moves to
|
||||
// Trash after the workspace lifetime). Stacking full-width keeps the longer
|
||||
// "New temporary note" label from clipping on narrow mobile widths.
|
||||
export default function NewNoteButton() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useGetSpacesQuery({ limit: 100 });
|
||||
|
||||
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
|
||||
|
||||
// No writable space → nothing to create in; render nothing.
|
||||
if (writableSpaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={false}
|
||||
label={t("New note")}
|
||||
icon={<IconPlus size={18} />}
|
||||
color="gray"
|
||||
/>
|
||||
<CreateNoteButton
|
||||
writableSpaces={writableSpaces}
|
||||
temporary={true}
|
||||
label={t("New temporary note")}
|
||||
icon={<IconHourglass size={18} />}
|
||||
color="orange"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getDefaultStore } from "jotai";
|
||||
|
||||
// Mock the app entry so importing the query module doesn't boot the whole app
|
||||
// (it only needs queryClient's cache methods, which we stub here). The spies are
|
||||
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
|
||||
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(() => undefined as unknown),
|
||||
invalidateQueries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData, getQueryData, invalidateQueries },
|
||||
}));
|
||||
|
||||
import { syncTemporaryExpiresInCache } from "./page-embed-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
|
||||
({
|
||||
id,
|
||||
slugId,
|
||||
name: id,
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
}) as unknown as SpaceTreeNode;
|
||||
|
||||
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getQueryData.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
const deadline = "2026-07-01T00:00:00.000Z";
|
||||
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
|
||||
|
||||
const next = store.get(treeDataAtom);
|
||||
// A new atom value was written...
|
||||
expect(next).not.toBe(tree);
|
||||
// ...the matching node gained the deadline...
|
||||
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
|
||||
// ...and the untouched sibling is unchanged.
|
||||
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
|
||||
const store = getDefaultStore();
|
||||
const tree = [mkNode("p1", "slug-1")];
|
||||
store.set(treeDataAtom, tree);
|
||||
|
||||
syncTemporaryExpiresInCache(
|
||||
{ id: "not-in-tree", slugId: "missing" },
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
);
|
||||
|
||||
// treeModel.update is a no-op (same reference) for an unknown id, so the
|
||||
// guard skips the store write entirely — same reference back.
|
||||
expect(store.get(treeDataAtom)).toBe(tree);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
|
||||
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
|
||||
import { getDefaultStore } from "jotai";
|
||||
import {
|
||||
toggleTemplate,
|
||||
toggleTemporary,
|
||||
} from "@/features/page-embed/services/page-embed-api";
|
||||
import type {
|
||||
ToggleTemplateResponse,
|
||||
ToggleTemporaryResponse,
|
||||
} from "@/features/page-embed/types/page-embed.types";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
|
||||
/**
|
||||
* After toggling a note's temporary state, mirror the new deadline into the
|
||||
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
|
||||
* menu label, the in-page banner, and the tree icon all reflect the change.
|
||||
* Centralised here so the header menu and the banner can't drift apart on the
|
||||
* cache-key plumbing.
|
||||
*/
|
||||
export function syncTemporaryExpiresInCache(
|
||||
page: { id: string; slugId: string },
|
||||
temporaryExpiresAt: string | null,
|
||||
) {
|
||||
for (const key of [page.slugId, page.id]) {
|
||||
const cached = queryClient.getQueryData<any>(["pages", key]);
|
||||
if (cached) {
|
||||
queryClient.setQueryData(["pages", key], {
|
||||
...cached,
|
||||
temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Patch the in-memory sidebar tree node so its temporary clock marker
|
||||
// appears/disappears immediately — WITHOUT a reload. The page cache update
|
||||
// above only drives the in-page banner/menu; the sidebar reads
|
||||
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
|
||||
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
|
||||
// store the sidebar's hooks read from. `treeModel.update` returns the same
|
||||
// reference (a no-op) when the page isn't in the currently loaded tree.
|
||||
const store = getDefaultStore();
|
||||
const prevTree = store.get(treeDataAtom);
|
||||
const nextTree = treeModel.update(prevTree, page.id, {
|
||||
temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["sidebar-pages"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemplateMutation() {
|
||||
return useMutation<
|
||||
@@ -18,3 +68,20 @@ export function useToggleTemplateMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleTemporaryMutation() {
|
||||
return useMutation<
|
||||
ToggleTemporaryResponse,
|
||||
Error,
|
||||
{ pageId: string; temporary?: boolean }
|
||||
>({
|
||||
mutationFn: (data) => toggleTemporary(data),
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message:
|
||||
err?.response?.data?.message || "Failed to update temporary note",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ToggleTemporaryResponse> {
|
||||
const r = await api.post("/pages/toggle-temporary", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -24,6 +25,10 @@ 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,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -160,6 +165,29 @@ 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 (menu label + banner) AND in
|
||||
// the sidebar tree node so its clock marker updates immediately, no reload.
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
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 +337,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleToggleTemporary}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
syncTemporaryExpiresInCache,
|
||||
} from "@/features/page-embed/queries/page-embed-query.ts";
|
||||
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,
|
||||
});
|
||||
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
|
||||
} catch {
|
||||
// mutation surfaces the error via notifications
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">
|
||||
<Trans
|
||||
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
|
||||
values={{ time: expiresTimeAgo }}
|
||||
/>
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { buildTree } from "@/features/page/tree/utils";
|
||||
import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -210,18 +210,15 @@ export function useRestorePageMutation() {
|
||||
|
||||
// Check if the page already exists in the tree (it shouldn't)
|
||||
if (!treeModel.find(currentTree, restoredPage.id)) {
|
||||
// Create the tree node data with hasChildren from backend
|
||||
const nodeData: SpaceTreeNode = {
|
||||
id: restoredPage.id,
|
||||
slugId: restoredPage.slugId,
|
||||
// Create the tree node data with hasChildren from backend. Routed
|
||||
// through the canonical mapper so the field copy stays in lockstep with
|
||||
// buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
|
||||
// page is made permanent), so the mapper carries that null through and
|
||||
// the node correctly shows no clock marker.
|
||||
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
|
||||
name: restoredPage.title || "Untitled",
|
||||
icon: restoredPage.icon,
|
||||
position: restoredPage.position,
|
||||
spaceId: restoredPage.spaceId,
|
||||
parentPageId: restoredPage.parentPageId,
|
||||
hasChildren: restoredPage.hasChildren || false,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Determine the parent and index
|
||||
const parentId = restoredPage.parentPageId || null;
|
||||
@@ -410,6 +407,11 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
slugId: data.slugId,
|
||||
spaceId: data.spaceId,
|
||||
title: data.title,
|
||||
// Carry the death-timer deadline so a note created as temporary keeps its
|
||||
// sidebar clock marker when the tree is rebuilt from this cached entry
|
||||
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
|
||||
// node's marker with `undefined`, hiding it until a reload.
|
||||
temporaryExpiresAt: data.temporaryExpiresAt,
|
||||
};
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -30,9 +31,13 @@ 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 { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
@@ -65,6 +70,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 +91,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);
|
||||
@@ -101,18 +131,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const currentIndex = siblings?.index ?? 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
const treeNodeData: SpaceTreeNode = {
|
||||
id: duplicatedPage.id,
|
||||
slugId: duplicatedPage.slugId,
|
||||
name: duplicatedPage.title,
|
||||
position: duplicatedPage.position,
|
||||
spaceId: duplicatedPage.spaceId,
|
||||
parentPageId: duplicatedPage.parentPageId,
|
||||
icon: duplicatedPage.icon,
|
||||
hasChildren: duplicatedPage.hasChildren,
|
||||
// Routed through the canonical mapper so the field copy stays in lockstep
|
||||
// with buildTree. The server does NOT arm a death timer on duplicate (the
|
||||
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
|
||||
// carries that null through and the duplicated node correctly shows no
|
||||
// clock marker — matching the server without a reload.
|
||||
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
|
||||
canEdit: true,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
setData((prev) =>
|
||||
treeModel.insert(prev, parentId, treeNodeData, newIndex),
|
||||
@@ -248,6 +274,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{isTemplate ? t("Unset as template") : t("Make template")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleTemporary();
|
||||
}}
|
||||
>
|
||||
{isTemporary ? t("Make permanent") : t("Make temporary")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconPlus,
|
||||
IconPointFilled,
|
||||
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{node.temporaryExpiresAt && (
|
||||
<Tooltip
|
||||
// Children ride along to trash with the note (recursive removePage).
|
||||
label={t("Temporary note — moves to trash unless made permanent")}
|
||||
withArrow
|
||||
>
|
||||
<IconClockHour4
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
// Same visual-only indicator pattern as the template icon, but
|
||||
// orange to flag the impending death timer.
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginLeft: rem(4),
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
}}
|
||||
aria-label={t("Temporary note")}
|
||||
role="img"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} canEdit={canEdit} />
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type { DropOp } from "@/features/page/tree/model/tree-model.types";
|
||||
import { dropOpToMovePayload } from "./drop-op-to-move-payload";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
@@ -22,7 +23,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
handleCreate: (parentId: string | null) => Promise<void>;
|
||||
handleCreate: (
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
@@ -119,9 +123,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 {
|
||||
@@ -130,16 +140,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
throw new Error("Failed to create page");
|
||||
}
|
||||
|
||||
const newNode: SpaceTreeNode = {
|
||||
id: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
// Route through the canonical mapper so the field copy (esp.
|
||||
// `temporaryExpiresAt`, which shows the temporary-note clock marker on
|
||||
// optimistic insert) can't drift from buildTree. `name: ""` because a
|
||||
// freshly created page is untitled; `hasChildren: false` because it has no
|
||||
// children yet.
|
||||
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
|
||||
name: "",
|
||||
position: createdPage.position,
|
||||
spaceId: createdPage.spaceId,
|
||||
parentPageId: createdPage.parentPageId,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Read latest tree at call time. Without this, callers that mutate the
|
||||
// tree (e.g. lazy-load children on expand) immediately before calling
|
||||
@@ -162,7 +171,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||
// the ids match exactly regardless of which path runs first.
|
||||
setData((prev) => {
|
||||
if (treeModel.find(prev, newNode.id)) return prev;
|
||||
const existing = treeModel.find(prev, newNode.id);
|
||||
if (existing) {
|
||||
// The server `addTreeNode` broadcast won the race and already inserted
|
||||
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
|
||||
// a temporary note WITHOUT its clock marker until reload; patch it on
|
||||
// from the authoritative create response so the marker shows now.
|
||||
if (
|
||||
newNode.temporaryExpiresAt &&
|
||||
!(existing as SpaceTreeNode).temporaryExpiresAt
|
||||
) {
|
||||
return treeModel.update(prev, newNode.id, {
|
||||
temporaryExpiresAt: newNode.temporaryExpiresAt,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||
});
|
||||
|
||||
|
||||
@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// handleCreate race-guard temporaryExpiresAt patch: when the server's
|
||||
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
|
||||
// updater runs, the updater must not re-insert. Two sub-branches:
|
||||
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
|
||||
// omitted it) while the authoritative create response DOES → patch the
|
||||
// deadline on so the clock marker shows now, without a reload.
|
||||
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
|
||||
// `prev` by reference (a no-op write).
|
||||
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
|
||||
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
|
||||
|
||||
// Mirrors the setData updater in use-tree-mutation handleCreate.
|
||||
const applyOptimisticInsert = (
|
||||
tree: TN[],
|
||||
parentId: string | null,
|
||||
node: TN,
|
||||
index: number,
|
||||
): TN[] => {
|
||||
const existing = treeModel.find(tree, node.id) as TN | null;
|
||||
if (existing) {
|
||||
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
|
||||
return treeModel.update(tree, node.id, {
|
||||
temporaryExpiresAt: node.temporaryExpiresAt,
|
||||
});
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
return treeModel.insert(tree, parentId, node, index);
|
||||
};
|
||||
|
||||
const fixtureTN: TN[] = [
|
||||
{ id: "a", name: "A" },
|
||||
{ id: "b", name: "B" },
|
||||
];
|
||||
|
||||
const deadline = "2026-07-01T00:00:00.000Z";
|
||||
|
||||
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
|
||||
// Server broadcast won the race and inserted the node WITHOUT a deadline.
|
||||
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||
id: "new",
|
||||
name: "",
|
||||
});
|
||||
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// The authoritative create response carries the deadline.
|
||||
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
|
||||
const patched = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
|
||||
// A new reference (the patch wrote) and the node now has the deadline...
|
||||
expect(patched).not.toBe(afterServer);
|
||||
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
|
||||
deadline,
|
||||
);
|
||||
// ...and still exactly one node (no duplicate re-insert).
|
||||
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
|
||||
const existingDeadline = deadline;
|
||||
// The node already exists WITH a deadline (the broadcast carried it).
|
||||
const afterServer = treeModel.insert(fixtureTN, null, {
|
||||
id: "new",
|
||||
name: "",
|
||||
temporaryExpiresAt: existingDeadline,
|
||||
});
|
||||
|
||||
// The create response carries a DIFFERENT deadline; the guard must ignore it.
|
||||
const created: TN = {
|
||||
id: "new",
|
||||
name: "",
|
||||
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
|
||||
};
|
||||
const after = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
|
||||
// prev returned by reference (no write) and the original deadline is kept.
|
||||
expect(after).toBe(afterServer);
|
||||
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
|
||||
existingDeadline,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -9,25 +9,45 @@ export function sortPositionKeys(keys: any[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
|
||||
* materialises a tree node from a page (buildTree, the optimistic insert in
|
||||
* handleCreate, restore, duplicate) routes through here so the field copy —
|
||||
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
|
||||
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
|
||||
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
|
||||
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
|
||||
* so restore (which the server nulls) stays permanent and a temporary create
|
||||
* keeps its clock marker without a reload.
|
||||
*/
|
||||
export function pageToTreeNode(
|
||||
page: IPage,
|
||||
overrides?: Partial<SpaceTreeNode>,
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt,
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||
|
||||
const tree: SpaceTreeNode[] = [];
|
||||
|
||||
pages.forEach((page) => {
|
||||
pageMap[page.id] = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
name: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
hasChildren: page.hasChildren,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
canEdit: page.canEdit ?? page.permissions?.canEdit,
|
||||
isTemplate: page.isTemplate,
|
||||
children: [],
|
||||
};
|
||||
pageMap[page.id] = pageToTreeNode(page);
|
||||
});
|
||||
|
||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { IShareAlias } from "@/features/share/types/share.types";
|
||||
|
||||
// matchMedia / storage are stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The mutation + query hooks reach react-query/network; the availability probe
|
||||
// hits the API. Stub them so the section renders in isolation and we can drive
|
||||
// the exact branches (taken name -> hint, 409 -> reassign modal).
|
||||
const setMutateAsync = vi.fn();
|
||||
let currentAlias: IShareAlias | null = null;
|
||||
let availabilityResult: {
|
||||
valid: boolean;
|
||||
available: boolean;
|
||||
currentPageId: string | null;
|
||||
} = { valid: true, available: true, currentPageId: null };
|
||||
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||
useSetShareAliasMutation: () => ({
|
||||
mutateAsync: setMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useRemoveShareAliasMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/share/services/share-service.ts", () => ({
|
||||
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
|
||||
}));
|
||||
|
||||
import ShareAliasSection from "./share-alias-section";
|
||||
|
||||
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
|
||||
id: `alias-${alias}`,
|
||||
workspaceId: "ws-1",
|
||||
alias,
|
||||
pageId,
|
||||
creatorId: "user-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
function renderSection(pageId = "page-Y") {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ShareAliasSection pageId={pageId} readOnly={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
|
||||
beforeEach(() => {
|
||||
setMutateAsync.mockReset();
|
||||
currentAlias = null;
|
||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
||||
});
|
||||
|
||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||
// Page Y already owns "bee"; the user retypes a name owned by page X.
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
// The reassign hint replaces the old dead-end red error.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This address is in use. Saving will move it to this page.",
|
||||
),
|
||||
).toBeDefined(),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
// The old terminal "already in use" error must NOT be shown.
|
||||
expect(screen.queryByText("This address is already in use")).toBeNull();
|
||||
|
||||
// Save stays enabled so the confirm-reassign flow can run.
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
|
||||
currentAlias = aliasRow("bee", "page-Y");
|
||||
availabilityResult = {
|
||||
valid: true,
|
||||
available: false,
|
||||
currentPageId: "page-X",
|
||||
};
|
||||
// The server rejects the un-confirmed save asking the client to confirm.
|
||||
setMutateAsync.mockRejectedValueOnce({
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
code: "ALIAS_REASSIGN_REQUIRED",
|
||||
currentPageId: "page-X",
|
||||
currentPageTitle: "Alias Test Page X",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderSection("page-Y");
|
||||
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "test2" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", {
|
||||
name: "Save",
|
||||
}) as HTMLButtonElement;
|
||||
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
|
||||
timeout: 2000,
|
||||
});
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
// First save sent WITHOUT confirmReassign.
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: false }),
|
||||
),
|
||||
);
|
||||
|
||||
// The "Move custom address?" confirm modal must appear (the path forward).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Move custom address?")).toBeDefined(),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
|
||||
|
||||
// Confirming retries WITH confirmReassign: true.
|
||||
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
|
||||
await waitFor(() =>
|
||||
expect(setMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alias: "test2", confirmReassign: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
@@ -119,15 +120,33 @@ export default function ShareAliasSection({
|
||||
};
|
||||
|
||||
const showInvalid = normalized.length > 0 && !isValid;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
|
||||
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
|
||||
// the "Move custom address?" confirm modal that retargets the address here.
|
||||
// So surface it as an informational hint (not a terminal red error) and keep
|
||||
// Save enabled, instead of looking like the address is unusable.
|
||||
const reassignable =
|
||||
isValid && !unchanged && !!availability && !availability.available;
|
||||
|
||||
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
||||
const prefixLabel = aliasPrefixLabel();
|
||||
const prefixRef = useRef<HTMLDivElement>(null);
|
||||
const [prefixWidth, setPrefixWidth] = useState(0);
|
||||
|
||||
// Measure the real rendered width of the prefix so the slug input sits flush
|
||||
// next to it, instead of after an over-estimated character-counted gap.
|
||||
useLayoutEffect(() => {
|
||||
if (prefixRef.current) {
|
||||
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
|
||||
}
|
||||
}, [prefixLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
<Text size="xs" c="dimmed" mb={6}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
@@ -159,23 +178,41 @@ export default function ShareAliasSection({
|
||||
// visibly to what gets stored.
|
||||
onBlur={() => setValue(normalized)}
|
||||
leftSection={
|
||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
||||
{aliasPrefixLabel()}
|
||||
</Text>
|
||||
<Box
|
||||
ref={prefixRef}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
paddingInline: "var(--mantine-spacing-xs)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
backgroundColor: "var(--mantine-color-default-hover)",
|
||||
borderTopLeftRadius: "var(--input-radius)",
|
||||
borderBottomLeftRadius: "var(--input-radius)",
|
||||
}}
|
||||
>
|
||||
{prefixLabel}
|
||||
</Box>
|
||||
}
|
||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
||||
leftSectionWidth={prefixWidth || undefined}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
description={
|
||||
reassignable
|
||||
? t("This address is in use. Saving will move it to this page.")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="xs" gap="xs">
|
||||
<Group mt="sm" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={classes.navbar}>
|
||||
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page,
|
||||
) && (
|
||||
<Tooltip label={t("Create page")} withArrow position="right">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
<>
|
||||
<Tooltip
|
||||
label={t("Create page")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreatePage}
|
||||
aria-label={t("Create page")}
|
||||
>
|
||||
<IconPlus />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Standalone second button: a "temporary note" auto-moves to
|
||||
trash after the workspace lifetime unless made permanent. */}
|
||||
<Tooltip
|
||||
label={t("New temporary note")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={18}
|
||||
onClick={handleCreateTemporaryPage}
|
||||
aria-label={t("New temporary note")}
|
||||
>
|
||||
<IconHourglass />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
// Space-overview quick actions: create a regular note or a temporary note
|
||||
// (which auto-moves to Trash after the workspace lifetime) directly in the
|
||||
// current space and open it. Mirrors the sidebar's create buttons but lives on
|
||||
// the space overview screen, reusing `useTreeMutation.handleCreate` so the new
|
||||
// page is optimistically inserted into the sidebar tree and navigated to.
|
||||
export default function SpaceCreateNoteButtons() {
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
// `handleCreate` is read unconditionally to keep hook order stable; it is
|
||||
// only invoked after the permission guard below confirms a loaded space.
|
||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||
// Which create action is in flight: drives the per-button spinner and the
|
||||
// shared disabled state so a slow create round-trip cannot be double-fired.
|
||||
const [pending, setPending] = useState<"regular" | "temporary" | null>(null);
|
||||
|
||||
// Render nothing until the space loads, or when the user cannot manage pages.
|
||||
if (!space) return null;
|
||||
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createNote = (temporary: boolean) => {
|
||||
if (pending) return;
|
||||
setPending(temporary ? "temporary" : "regular");
|
||||
// handleCreate creates the page then navigates away (unmounting this
|
||||
// component); the create mutation already shows a red notification on
|
||||
// failure, so swallow the rejection and just clear the pending flag.
|
||||
handleCreate(null, temporary ? { temporary: true } : undefined)
|
||||
.catch(() => {})
|
||||
.finally(() => setPending(null));
|
||||
};
|
||||
|
||||
// Two full-width, vertically stacked buttons: a neutral regular note and an
|
||||
// orange-tinted temporary note. Stacking full-width keeps the longer "New
|
||||
// temporary note" label from clipping on narrow mobile widths.
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
fullWidth
|
||||
leftSection={<IconPlus size={18} />}
|
||||
loading={pending === "regular"}
|
||||
disabled={pending !== null}
|
||||
onClick={() => createNote(false)}
|
||||
>
|
||||
{t("New note")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="light"
|
||||
color="orange"
|
||||
fullWidth
|
||||
leftSection={<IconHourglass size={18} />}
|
||||
loading={pending === "temporary"}
|
||||
disabled={pending !== null}
|
||||
onClick={() => createNote(true)}
|
||||
>
|
||||
{t("New temporary note")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -323,4 +323,18 @@ describe("applyAddTreeNode", () => {
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries temporaryExpiresAt onto the inserted node so the clock marker shows on create (no reload)", () => {
|
||||
// A note created as temporary broadcasts addTreeNode with the death-timer
|
||||
// deadline in its payload; the receiver's inserted node must keep it so
|
||||
// space-tree-row renders the orange clock marker immediately.
|
||||
const tree = roots();
|
||||
const expiresAt = "2026-06-27T21:00:00.000Z";
|
||||
const next = applyAddTreeNode(tree, {
|
||||
parentId: null as unknown as string,
|
||||
index: 0,
|
||||
data: node("temp", { position: "a3", temporaryExpiresAt: expiresAt }),
|
||||
});
|
||||
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useAiRoleCatalogBundleQuery,
|
||||
useAiRoleCatalogQuery,
|
||||
useImportAiRolesFromCatalogMutation,
|
||||
useUpdateAiRoleFromCatalogMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
IAiRole,
|
||||
IAiRoleCatalogBundleSummary,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||
|
||||
interface AiAgentRolesCatalogModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// The current admin role list (full view, including `source`). Used to compute
|
||||
// each catalog role's install state (import / installed / update available).
|
||||
roles: IAiRole[];
|
||||
}
|
||||
|
||||
/** How a name collision with an existing role is handled on import. */
|
||||
type Conflict = "skip" | "rename";
|
||||
|
||||
/**
|
||||
* Admin modal: browse the curated role catalog, import roles, and update an
|
||||
* imported role when the catalog ships a newer version.
|
||||
*
|
||||
* Import is per-bundle (the endpoint takes a single bundleId). Each bundle's
|
||||
* Accordion panel has its own "Import" button that imports only that bundle's
|
||||
* checked roles — the simplest mapping to the one-bundle-per-call API and the
|
||||
* clearest UX. Selection state is tracked per bundle.
|
||||
*/
|
||||
export default function AiAgentRolesCatalogModal({
|
||||
opened,
|
||||
onClose,
|
||||
roles,
|
||||
}: AiAgentRolesCatalogModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// The user's i18n base subtag (e.g. "ru-RU" => "ru"); the preferred catalog
|
||||
// language both when seeding and when reconciling against offered languages.
|
||||
const baseLang = (i18n.language || "en").split("-")[0].toLowerCase();
|
||||
|
||||
// Fetch the catalog only while the modal is open. `language` drives both the
|
||||
// catalog query (bundle names) and bundle reads (role content). Seed it
|
||||
// synchronously from the base subtag so the first fetch already uses the
|
||||
// user's language; the effect below still reconciles against the catalog's
|
||||
// offered languages once they load.
|
||||
const [language, setLanguage] = useState<string>(() => baseLang);
|
||||
const catalogQuery = useAiRoleCatalogQuery(language || "en", opened);
|
||||
|
||||
// On name conflict: Skip (default) or Rename to a free " (N)" name.
|
||||
const [conflict, setConflict] = useState<Conflict>("skip");
|
||||
|
||||
// The currently expanded bundle id (Accordion is single-open: one bundle's
|
||||
// roles are fetched at a time).
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
// Per-bundle selected slugs (import-state roles checked for import).
|
||||
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
const languages = catalogQuery.data?.languages;
|
||||
|
||||
// Pick a sensible default language from the catalog once it loads: the i18n
|
||||
// base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first.
|
||||
useEffect(() => {
|
||||
if (!languages || languages.length === 0) return;
|
||||
if (language && languages.includes(language)) return;
|
||||
const preferred = languages.includes(baseLang)
|
||||
? baseLang
|
||||
: languages.includes("en")
|
||||
? "en"
|
||||
: languages[0];
|
||||
setLanguage(preferred);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [languages]);
|
||||
|
||||
// Reset per-language UI state when the language changes (the bundle content,
|
||||
// hence the install computations, are language-specific).
|
||||
useEffect(() => {
|
||||
setExpanded(null);
|
||||
setSelected({});
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Role catalog")}
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("Language")}
|
||||
data={languages ?? []}
|
||||
value={language || null}
|
||||
onChange={(value) => value && setLanguage(value)}
|
||||
allowDeselect={false}
|
||||
disabled={!languages || languages.length === 0}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
|
||||
<Radio.Group
|
||||
label={t("On name conflict")}
|
||||
value={conflict}
|
||||
onChange={(value) => setConflict(value as Conflict)}
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="skip" label={t("Skip")} />
|
||||
<Radio value="rename" label={t("Rename")} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
{catalogQuery.isLoading && (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{catalogQuery.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("The role catalog is unavailable")}
|
||||
>
|
||||
{t("Please try again later.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{catalogQuery.data && catalogQuery.data.bundles.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No bundles available")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{catalogQuery.data && catalogQuery.data.bundles.length > 0 && (
|
||||
<Accordion
|
||||
variant="separated"
|
||||
value={expanded}
|
||||
onChange={setExpanded}
|
||||
>
|
||||
{catalogQuery.data.bundles.map((bundle) => (
|
||||
<BundlePanel
|
||||
key={bundle.id}
|
||||
bundle={bundle}
|
||||
language={language}
|
||||
expanded={expanded === bundle.id}
|
||||
roles={roles}
|
||||
conflict={conflict}
|
||||
selected={selected[bundle.id]}
|
||||
onToggleSlug={(slug, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev[bundle.id] ?? []);
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
return { ...prev, [bundle.id]: next };
|
||||
})
|
||||
}
|
||||
onSetSelected={(slugs) =>
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
[bundle.id]: new Set(slugs),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface BundlePanelProps {
|
||||
bundle: IAiRoleCatalogBundleSummary;
|
||||
language: string;
|
||||
expanded: boolean;
|
||||
roles: IAiRole[];
|
||||
conflict: Conflict;
|
||||
selected: Set<string> | undefined;
|
||||
onToggleSlug: (slug: string, checked: boolean) => void;
|
||||
onSetSelected: (slugs: string[]) => void;
|
||||
}
|
||||
|
||||
/** One catalog bundle: its roles (fetched when expanded) + a per-bundle import. */
|
||||
function BundlePanel({
|
||||
bundle,
|
||||
language,
|
||||
expanded,
|
||||
roles,
|
||||
conflict,
|
||||
selected,
|
||||
onToggleSlug,
|
||||
onSetSelected,
|
||||
}: BundlePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only fetch this bundle's roles once it is actually expanded.
|
||||
const bundleQuery = useAiRoleCatalogBundleQuery(
|
||||
bundle.id,
|
||||
language,
|
||||
expanded && !!language,
|
||||
);
|
||||
|
||||
const importMutation = useImportAiRolesFromCatalogMutation();
|
||||
const updateMutation = useUpdateAiRoleFromCatalogMutation();
|
||||
|
||||
// Compute each catalog role's install state against the current workspace
|
||||
// roles (matched by source.slug + source.language). The decision lives in the
|
||||
// pure `catalogRoleInstallState` helper so it is unit-tested directly.
|
||||
const computed = useMemo(() => {
|
||||
const list = bundleQuery.data?.roles ?? [];
|
||||
return list.map((role) => ({
|
||||
role,
|
||||
...catalogRoleInstallState(role, roles, language),
|
||||
}));
|
||||
}, [bundleQuery.data, roles, language]);
|
||||
|
||||
// Default-check every importable role once the bundle content arrives (unless
|
||||
// the user already touched the selection for this bundle).
|
||||
useEffect(() => {
|
||||
if (!bundleQuery.data || selected !== undefined) return;
|
||||
onSetSelected(
|
||||
computed.filter((c) => c.state === "import").map((c) => c.role.slug),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bundleQuery.data]);
|
||||
|
||||
const importableSlugs = computed
|
||||
.filter((c) => c.state === "import")
|
||||
.map((c) => c.role.slug);
|
||||
const checkedSlugs = importableSlugs.filter((slug) => selected?.has(slug));
|
||||
|
||||
function handleImport() {
|
||||
importMutation.mutate({
|
||||
bundleId: bundle.id,
|
||||
language,
|
||||
slugs: checkedSlugs,
|
||||
conflict,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item value={bundle.id}>
|
||||
<Accordion.Control>
|
||||
<Stack gap={2}>
|
||||
<Text fw={500}>{bundle.name}</Text>
|
||||
{bundle.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{bundle.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{bundleQuery.isLoading && (
|
||||
<Center py="md">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{bundleQuery.isError && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("The role catalog is unavailable")}
|
||||
>
|
||||
{t("Please try again later.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{bundleQuery.data && (
|
||||
<Stack gap="xs">
|
||||
{computed.map((entry) => (
|
||||
<CatalogRoleRow
|
||||
key={entry.role.slug}
|
||||
role={entry.role}
|
||||
state={entry.state}
|
||||
checked={
|
||||
entry.state === "import"
|
||||
? !!selected?.has(entry.role.slug)
|
||||
: false
|
||||
}
|
||||
onToggle={(checked) => onToggleSlug(entry.role.slug, checked)}
|
||||
fromVersion={
|
||||
entry.state === "update" ? entry.fromVersion : undefined
|
||||
}
|
||||
onUpdate={
|
||||
entry.state === "update"
|
||||
? () => updateMutation.mutate(entry.installed.id)
|
||||
: undefined
|
||||
}
|
||||
updating={updateMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleImport}
|
||||
loading={importMutation.isPending}
|
||||
disabled={checkedSlugs.length === 0}
|
||||
>
|
||||
{t("Import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
interface CatalogRoleRowProps {
|
||||
role: IAiRoleCatalogRole;
|
||||
state: "import" | "installed" | "update";
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
// The installed role's current source version (only set in the "update" state).
|
||||
fromVersion?: number;
|
||||
onUpdate?: () => void;
|
||||
updating: boolean;
|
||||
}
|
||||
|
||||
/** A single catalog role row with its install-state affordance. */
|
||||
function CatalogRoleRow({
|
||||
role,
|
||||
state,
|
||||
checked,
|
||||
onToggle,
|
||||
fromVersion,
|
||||
onUpdate,
|
||||
updating,
|
||||
}: CatalogRoleRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
{state === "import" && (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(event) => onToggle(event.currentTarget.checked)}
|
||||
aria-label={role.name}
|
||||
/>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fw={500} truncate>
|
||||
{role.emoji ? `${role.emoji} ` : ""}
|
||||
{role.name}
|
||||
</Text>
|
||||
{role.description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{role.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
|
||||
{state === "installed" && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("Installed")}
|
||||
</Badge>
|
||||
)}
|
||||
{state === "update" && (
|
||||
<>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{t("v{{from}} → v{{to}}", {
|
||||
from: fromVersion ?? 0,
|
||||
to: role.version,
|
||||
})}
|
||||
</Badge>
|
||||
<Button size="xs" variant="light" onClick={onUpdate} loading={updating}>
|
||||
{t("Update")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconPackageImport,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
||||
import AiAgentRolesCatalogModal from "./ai-agent-roles-catalog-modal.tsx";
|
||||
|
||||
/**
|
||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||
@@ -39,6 +45,9 @@ export default function AiAgentRoles() {
|
||||
const deleteMutation = useDeleteAiRoleMutation();
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
// Separate disclosure for the catalog (import/update) modal.
|
||||
const [catalogOpened, { open: openCatalog, close: closeCatalog }] =
|
||||
useDisclosure(false);
|
||||
// The role being edited; undefined => the modal is in "create" mode.
|
||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||
|
||||
@@ -86,14 +95,24 @@ export default function AiAgentRoles() {
|
||||
/>
|
||||
<Text fw={600}>{t("Agent roles")}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
leftSection={<IconPackageImport size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCatalog}
|
||||
>
|
||||
{t("Import from catalog")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
@@ -102,9 +121,19 @@ export default function AiAgentRoles() {
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!roles || roles.length === 0) && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
<Group gap="sm" mt="sm" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconPackageImport size={16} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={openCatalog}
|
||||
>
|
||||
{t("Browse the catalog")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
@@ -170,6 +199,12 @@ export default function AiAgentRoles() {
|
||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||
</Modal>
|
||||
|
||||
<AiAgentRolesCatalogModal
|
||||
opened={catalogOpened}
|
||||
onClose={closeCatalog}
|
||||
roles={roles ?? []}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number>(
|
||||
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<IWorkspace>);
|
||||
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 (
|
||||
<Stack mt="sm">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Temporary notes")}
|
||||
</Text>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{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.",
|
||||
)}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label={t("Temporary note lifetime (hours)")}
|
||||
min={1}
|
||||
allowDecimal={false}
|
||||
value={value}
|
||||
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
|
||||
disabled={!isAdmin || isLoading}
|
||||
w={220}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export interface IWorkspace {
|
||||
plan?: string;
|
||||
enforceMfa?: boolean;
|
||||
aiSearch?: boolean;
|
||||
generativeAi?: boolean;
|
||||
disablePublicSharing?: boolean;
|
||||
mcpEnabled?: boolean;
|
||||
aiChat?: boolean;
|
||||
@@ -28,6 +27,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;
|
||||
@@ -59,7 +60,6 @@ export interface IWorkspaceApiSettings {
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
generative?: boolean;
|
||||
mcp?: boolean;
|
||||
chat?: boolean;
|
||||
dictation?: boolean;
|
||||
|
||||
@@ -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() {
|
||||
<WorkspaceNameForm />
|
||||
<HtmlEmbedSettings />
|
||||
<TrackerSettings />
|
||||
<TemporaryNoteSettings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Container} from "@mantine/core";
|
||||
import {Container, Space} from "@mantine/core";
|
||||
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
|
||||
import SpaceCreateNoteButtons from "@/features/space/components/space-create-note-buttons.tsx";
|
||||
import {useParams} from "react-router-dom";
|
||||
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
|
||||
import {getAppName} from "@/lib/config.ts";
|
||||
@@ -15,7 +16,13 @@ export default function SpaceHome() {
|
||||
<title>{space?.name || 'Overview'} - {getAppName()}</title>
|
||||
</Helmet>
|
||||
<Container size={"900"} pt="xl">
|
||||
{space && <SpaceHomeTabs/>}
|
||||
{space && (
|
||||
<>
|
||||
<SpaceCreateNoteButtons/>
|
||||
<Space h="md"/>
|
||||
<SpaceHomeTabs/>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
ChatIdDto,
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatMessagesDto,
|
||||
RenameChatDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
@@ -316,6 +317,43 @@ export class AiChatController {
|
||||
return { text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a page title from supplied note content (#199). One-shot,
|
||||
* non-streaming. Gated by the AI chat flag (settings.ai.chat, the same toggle
|
||||
* that enables the chat agent); returns { title }.
|
||||
* The endpoint NEVER writes the page — the client applies the title via the
|
||||
* existing /pages/update route (which enforces edit permission), so access
|
||||
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } })
|
||||
@Post('generate-page-title')
|
||||
async generatePageTitle(
|
||||
@Body() dto: GeneratePageTitleDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ title: string }> {
|
||||
const settings = (workspace.settings ?? {}) as {
|
||||
ai?: { chat?: boolean };
|
||||
};
|
||||
if (settings.ai?.chat !== true) {
|
||||
throw new ForbiddenException('AI title generation is disabled');
|
||||
}
|
||||
try {
|
||||
const title = await this.aiChatService.generatePageTitle(
|
||||
workspace.id,
|
||||
dto.content,
|
||||
);
|
||||
return { title };
|
||||
} catch (err) {
|
||||
// Preserve meaningful HTTP errors (e.g. AiNotConfiguredException -> 503).
|
||||
if (err instanceof HttpException) throw err;
|
||||
// Surface the real provider/transport reason instead of an opaque 500.
|
||||
this.logger.error('AI title generation failed', err as Error);
|
||||
throw new ServiceUnavailableException(describeProviderError(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chat exists, belongs to this workspace, AND was created by the
|
||||
* requesting user (per-user isolation). Throws ForbiddenException otherwise.
|
||||
|
||||
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal file
122
apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { cleanGeneratedTitle } from './ai-chat.service';
|
||||
import type { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Pure post-processing of a model-generated title (#199): trims, strips a single
|
||||
* pair of surrounding quotes, drops a trailing period, and hard-caps the length.
|
||||
*/
|
||||
describe('cleanGeneratedTitle', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(cleanGeneratedTitle(' Hello world ')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('strips a single pair of surrounding double quotes', () => {
|
||||
expect(cleanGeneratedTitle('"My note"')).toBe('My note');
|
||||
});
|
||||
|
||||
it('strips surrounding single quotes', () => {
|
||||
expect(cleanGeneratedTitle("'My note'")).toBe('My note');
|
||||
});
|
||||
|
||||
it('drops a trailing period', () => {
|
||||
expect(cleanGeneratedTitle('A complete sentence.')).toBe(
|
||||
'A complete sentence',
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the result at 255 characters (the page-title column bound)', () => {
|
||||
expect(cleanGeneratedTitle('x'.repeat(400))).toHaveLength(255);
|
||||
});
|
||||
|
||||
it('returns an empty string for blank/garbage input', () => {
|
||||
expect(cleanGeneratedTitle(' ')).toBe('');
|
||||
expect(cleanGeneratedTitle('""')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
|
||||
* gate on settings.ai.chat (403 when off), delegate to the service when on,
|
||||
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
|
||||
* any other provider/transport fault to a 503. Exercised by instantiating the
|
||||
* controller with hand-rolled mocks — no Nest graph, no DB.
|
||||
*/
|
||||
describe('AiChatController.generatePageTitle', () => {
|
||||
const enabledWorkspace = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: true } },
|
||||
} as unknown as Workspace;
|
||||
|
||||
function makeController(generate: jest.Mock) {
|
||||
const aiChatService = { generatePageTitle: generate };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatService };
|
||||
}
|
||||
|
||||
it('forbids when the AI chat flag is off', async () => {
|
||||
const generate = jest.fn();
|
||||
const { controller } = makeController(generate);
|
||||
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, disabled),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids when settings.ai.chat is anything but exactly true', async () => {
|
||||
const generate = jest.fn();
|
||||
const { controller } = makeController(generate);
|
||||
const ws = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: 'yes' } },
|
||||
} as unknown as Workspace;
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, ws),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('returns { title } from the service when enabled', async () => {
|
||||
const generate = jest.fn().mockResolvedValue('Generated Title');
|
||||
const { controller } = makeController(generate);
|
||||
const res = await controller.generatePageTitle(
|
||||
{ content: 'some markdown body' },
|
||||
enabledWorkspace,
|
||||
);
|
||||
expect(generate).toHaveBeenCalledWith('ws1', 'some markdown body');
|
||||
expect(res).toEqual({ title: 'Generated Title' });
|
||||
});
|
||||
|
||||
it('rethrows an HttpException from the service verbatim (e.g. 503 not configured)', async () => {
|
||||
const notConfigured = new ServiceUnavailableException('AI not configured');
|
||||
const generate = jest.fn().mockRejectedValue(notConfigured);
|
||||
const { controller } = makeController(generate);
|
||||
await expect(
|
||||
controller.generatePageTitle({ content: 'body' }, enabledWorkspace),
|
||||
).rejects.toBe(notConfigured);
|
||||
});
|
||||
|
||||
it('maps a non-HTTP provider error to a 503', async () => {
|
||||
const generate = jest.fn().mockRejectedValue(new Error('socket hang up'));
|
||||
const { controller } = makeController(generate);
|
||||
// Silence the expected error log.
|
||||
jest
|
||||
.spyOn((controller as unknown as { logger: { error: () => void } }).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const err = await controller
|
||||
.generatePageTitle({ content: 'body' }, enabledWorkspace)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
});
|
||||
});
|
||||
@@ -241,7 +241,7 @@ describe('prepareAgentStep', () => {
|
||||
* write path. It runs identically for the upfront insert (empty steps,
|
||||
* 'streaming'), every per-step update, and the terminal finalize — so a future
|
||||
* background worker can call the same function. These tests pin the four status
|
||||
* shapes and the `metadata.parts` shape that rowToUiMessage/findRecent depend on
|
||||
* shapes and the `metadata.parts` shape that rowToUiMessage/findAllByChat depend on
|
||||
* (per-step text + tool parts via assistantParts, in-progress text appended).
|
||||
*/
|
||||
describe('flushAssistant', () => {
|
||||
|
||||
@@ -75,6 +75,18 @@ export function prepareAgentStep(
|
||||
|
||||
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
|
||||
|
||||
// Pure, unit-testable post-processing for a model-generated title (#199): trim
|
||||
// whitespace, strip a single pair of surrounding quotes the model often adds,
|
||||
// drop a trailing period, and hard-cap the length to the page-title column.
|
||||
export function cleanGeneratedTitle(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, unit-testable (#198): decide whether THIS turn is an interrupt-resume,
|
||||
* i.e. it directly follows a user interruption of the previous (still-partial)
|
||||
@@ -353,12 +365,14 @@ export class AiChatService implements OnModuleInit {
|
||||
|
||||
// Rebuild the conversation from persisted history (not the client payload),
|
||||
// so the model always sees the authoritative server-side transcript. Load
|
||||
// the most RECENT tail (oldest -> newest) so chats longer than one page do
|
||||
// not drop recent turns (incl. the user message just inserted above).
|
||||
const history = await this.aiChatMessageRepo.findRecent(
|
||||
// the FULL history in chronological order (oldest -> newest, incl. the user
|
||||
// message just inserted above) so NO turns are dropped — there is no
|
||||
// recent-tail window anymore. `findAllByChat` keeps a 5000-row memory-safety
|
||||
// backstop (on overflow it keeps the NEWEST rows and logs a warning); that
|
||||
// is a safety net far above any realistic chat, not a conversational limit.
|
||||
const history = await this.aiChatMessageRepo.findAllByChat(
|
||||
chatId,
|
||||
workspace.id,
|
||||
50,
|
||||
);
|
||||
const uiMessages = history.map(rowToUiMessage);
|
||||
// convertToModelMessages is async in ai@6.0.134 (returns Promise<ModelMessage[]>).
|
||||
@@ -834,6 +848,27 @@ export class AiChatService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot page-title generation from a note's content (#199). No tools, no
|
||||
* streaming — mirrors generateTitle() but for an arbitrary note body supplied
|
||||
* by the client, and RETURNS the title instead of writing it (the client
|
||||
* applies it via the existing /pages/update route, which enforces edit
|
||||
* permission). The content is truncated to keep the prompt cheap and within
|
||||
* context limits. Throws AiNotConfiguredException (503) if AI is unconfigured.
|
||||
*/
|
||||
async generatePageTitle(workspaceId: string, content: string): Promise<string> {
|
||||
const model = await this.ai.getChatModel(workspaceId);
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system:
|
||||
'You generate a single concise, descriptive title for a note based on ' +
|
||||
'its content. Reply with the title only — at most 8 words, no quotes, ' +
|
||||
'no trailing punctuation, written in the same language as the note.',
|
||||
prompt: content.slice(0, 8000),
|
||||
});
|
||||
return cleanGeneratedTitle(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap, non-blocking title generation from the first user message. Uses
|
||||
* generateText (async) and writes the result back onto the chat row. Any
|
||||
@@ -1256,7 +1291,7 @@ export async function applyFinalize(
|
||||
*
|
||||
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
||||
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
||||
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
|
||||
* findAllByChat keep replaying the turn unchanged. `metadata.finishReason`,
|
||||
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
|
||||
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
|
||||
* the pre-#183 onFinish/onError records.
|
||||
|
||||
@@ -17,6 +17,16 @@ export class RenameChatDto {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** One-shot page-title generation from note content (#199). */
|
||||
export class GeneratePageTitleDto {
|
||||
// Note body as markdown/plain text. Capped to bound the prompt cost and
|
||||
// reject abusive payloads; the service truncates again before the model call.
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20000)
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Optional chat id for listing messages of a specific chat. */
|
||||
export class GetChatMessagesDto {
|
||||
@IsString()
|
||||
|
||||
@@ -39,6 +39,10 @@ describe('AiAgentRolesController admin gate', () => {
|
||||
create: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'r1' }),
|
||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||
getCatalog: jest.fn().mockResolvedValue({ languages: [], bundles: [] }),
|
||||
getCatalogBundle: jest.fn().mockResolvedValue({ roles: [] }),
|
||||
importFromCatalog: jest.fn().mockResolvedValue({ created: 0 }),
|
||||
updateFromCatalog: jest.fn().mockResolvedValue({ updated: false }),
|
||||
};
|
||||
const controller = new AiAgentRolesController(
|
||||
rolesService as never,
|
||||
@@ -109,6 +113,90 @@ describe('AiAgentRolesController admin gate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Catalog routes (browse + import) are ALL admin-only: a non-admin caller must
|
||||
// get ForbiddenException with the service untouched; an admin delegates with
|
||||
// the right arguments (import/update-from-catalog carry workspace.id).
|
||||
describe('catalog routes admin gate', () => {
|
||||
const catalogDto = { language: 'en' } as never;
|
||||
const bundleDto = { bundleId: 'general', language: 'en' } as never;
|
||||
const importDto = {
|
||||
bundleId: 'general',
|
||||
language: 'en',
|
||||
conflict: 'skip',
|
||||
} as never;
|
||||
const updateDto = { id: 'r1' } as never;
|
||||
|
||||
describe('non-admin is rejected and the service is NOT called', () => {
|
||||
it('catalog', async () => {
|
||||
const { controller, rolesService } = makeController(false);
|
||||
await expect(
|
||||
controller.catalog(catalogDto, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(rolesService.getCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('catalog/bundle', async () => {
|
||||
const { controller, rolesService } = makeController(false);
|
||||
await expect(
|
||||
controller.catalogBundle(bundleDto, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(rolesService.getCatalogBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('import', async () => {
|
||||
const { controller, rolesService } = makeController(false);
|
||||
await expect(
|
||||
controller.import(importDto, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(rolesService.importFromCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update-from-catalog', async () => {
|
||||
const { controller, rolesService } = makeController(false);
|
||||
await expect(
|
||||
controller.updateFromCatalog(updateDto, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(rolesService.updateFromCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin delegates to the service', () => {
|
||||
it('catalog passes the requested language', async () => {
|
||||
const { controller, rolesService } = makeController(true);
|
||||
await controller.catalog(catalogDto, user, workspace);
|
||||
expect(rolesService.getCatalog).toHaveBeenCalledWith('en');
|
||||
});
|
||||
|
||||
it('catalog/bundle passes bundleId + language', async () => {
|
||||
const { controller, rolesService } = makeController(true);
|
||||
await controller.catalogBundle(bundleDto, user, workspace);
|
||||
expect(rolesService.getCatalogBundle).toHaveBeenCalledWith(
|
||||
'general',
|
||||
'en',
|
||||
);
|
||||
});
|
||||
|
||||
it('import passes workspace.id + user.id + dto', async () => {
|
||||
const { controller, rolesService } = makeController(true);
|
||||
await controller.import(importDto, user, workspace);
|
||||
expect(rolesService.importFromCatalog).toHaveBeenCalledWith(
|
||||
'ws-1',
|
||||
'u1',
|
||||
importDto,
|
||||
);
|
||||
});
|
||||
|
||||
it('update-from-catalog passes workspace.id + dto', async () => {
|
||||
const { controller, rolesService } = makeController(true);
|
||||
await controller.updateFromCatalog(updateDto, user, workspace);
|
||||
expect(rolesService.updateFromCatalog).toHaveBeenCalledWith(
|
||||
'ws-1',
|
||||
updateDto,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list (member-reachable)', () => {
|
||||
it('non-admin reaches list and the service is asked for the picker view (isAdmin=false)', async () => {
|
||||
const { controller, rolesService } = makeController(false);
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
CreateAgentRoleDto,
|
||||
UpdateAgentRoleDto,
|
||||
} from './dto/agent-role.dto';
|
||||
import {
|
||||
CatalogBundleDto,
|
||||
CatalogQueryDto,
|
||||
ImportFromCatalogDto,
|
||||
UpdateFromCatalogDto,
|
||||
} from './dto/agent-role-catalog.dto';
|
||||
|
||||
/** Path/body param for the per-role routes (update/delete). */
|
||||
class AgentRoleIdDto {
|
||||
@@ -113,4 +119,54 @@ export class AiAgentRolesController {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.remove(workspace.id, idDto.id);
|
||||
}
|
||||
|
||||
// --- Catalog (admin-only): browse + import + update imported roles. ---
|
||||
|
||||
/** Browse the curated catalog (localized to dto.language). */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('catalog')
|
||||
async catalog(
|
||||
@Body() dto: CatalogQueryDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.getCatalog(dto.language);
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a language (role content + versions). */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('catalog/bundle')
|
||||
async catalogBundle(
|
||||
@Body() dto: CatalogBundleDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.getCatalogBundle(dto.bundleId, dto.language);
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('import')
|
||||
async import(
|
||||
@Body() dto: ImportFromCatalogDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.importFromCatalog(workspace.id, user.id, dto);
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update-from-catalog')
|
||||
async updateFromCatalog(
|
||||
@Body() dto: UpdateFromCatalogDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.rolesService.updateFromCatalog(workspace.id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiAgentRolesController } from './ai-agent-roles.controller';
|
||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
||||
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
|
||||
|
||||
/**
|
||||
* Agent roles unit (v1). Admin CRUD + member-visible listing for the chat
|
||||
* role picker. AiAgentRoleRepo (DatabaseModule, global) and
|
||||
* WorkspaceAbilityFactory (CaslModule, global) are resolved without explicit
|
||||
* imports. The stream-time role resolution + model override live in
|
||||
* AiChatService / AiService; this module only hosts the management API.
|
||||
* role picker, plus the admin catalog (browse/import/update). AiAgentRoleRepo
|
||||
* (DatabaseModule, global), WorkspaceAbilityFactory (CaslModule, global) and
|
||||
* EnvironmentService (EnvironmentModule, global — used by the catalog provider)
|
||||
* are resolved without explicit imports. The stream-time role resolution +
|
||||
* model override live in AiChatService / AiService; this module only hosts the
|
||||
* management API.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AiAgentRolesController],
|
||||
providers: [AiAgentRolesService],
|
||||
providers: [AiAgentRolesService, AiAgentRolesCatalogProvider],
|
||||
})
|
||||
export class AiAgentRolesModule {}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { AiAgentRolesService } from './ai-agent-roles.service';
|
||||
import type { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
import type {
|
||||
@@ -27,12 +32,22 @@ describe('AiAgentRolesService guards', () => {
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...over,
|
||||
} as AiAgentRole;
|
||||
}
|
||||
|
||||
// A stubbed catalog provider; the CRUD tests never reach it (they exercise
|
||||
// create/update/remove/list only), so the methods just reject if hit.
|
||||
function makeCatalog() {
|
||||
return {
|
||||
fetchIndex: jest.fn(),
|
||||
fetchBundle: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(opts: { existing?: AiAgentRole | undefined } = {}) {
|
||||
const repo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.existing),
|
||||
@@ -41,8 +56,9 @@ describe('AiAgentRolesService guards', () => {
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
listByWorkspace: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never);
|
||||
return { service, repo };
|
||||
const catalog = makeCatalog();
|
||||
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||
return { service, repo, catalog };
|
||||
}
|
||||
|
||||
describe('update', () => {
|
||||
@@ -163,6 +179,7 @@ describe('AiAgentRolesService guards', () => {
|
||||
enabled: false,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source: null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
@@ -397,7 +414,7 @@ describe('AiAgentRolesService guards', () => {
|
||||
softDelete: jest.fn(),
|
||||
listByWorkspace: jest.fn().mockResolvedValue(rows),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never);
|
||||
const service = new AiAgentRolesService(repo as never, makeCatalog() as never);
|
||||
return { service, repo };
|
||||
}
|
||||
|
||||
@@ -461,4 +478,630 @@ describe('AiAgentRolesService guards', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog: import (skip / rename / already-installed) and update reconciliation
|
||||
// against a MOCKED catalog provider + mocked repo (mirrors the CRUD style).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('importFromCatalog', () => {
|
||||
function catalogRole(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
instructions: 'be a researcher',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makeImportService(opts: {
|
||||
indexRoles?: { slug: string; version: number }[];
|
||||
bundleRoles?: Record<string, unknown>[];
|
||||
existing?: AiAgentRole[];
|
||||
}) {
|
||||
const index = {
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: opts.indexRoles ?? [{ slug: 'researcher', version: 3 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const bundle = {
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: opts.bundleRoles ?? [catalogRole()],
|
||||
};
|
||||
const repo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn().mockImplementation((v) => Promise.resolve(makeRow(v))),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
softDelete: jest.fn(),
|
||||
listByWorkspace: jest.fn().mockResolvedValue(opts.existing ?? []),
|
||||
};
|
||||
const catalog = {
|
||||
fetchIndex: jest.fn().mockResolvedValue(index),
|
||||
fetchBundle: jest.fn().mockResolvedValue(bundle),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||
return { service, repo, catalog };
|
||||
}
|
||||
|
||||
const dto = (over: Record<string, unknown> = {}) =>
|
||||
({
|
||||
bundleId: 'general',
|
||||
language: 'en',
|
||||
conflict: 'skip',
|
||||
...over,
|
||||
}) as never;
|
||||
|
||||
it('inserts a new role with source { slug, language, version } from the index', async () => {
|
||||
const { service, repo } = makeImportService({});
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
|
||||
expect(res.errors).toEqual([]);
|
||||
const values = repo.insert.mock.calls[0][0];
|
||||
expect(values.source).toEqual({
|
||||
slug: 'researcher',
|
||||
language: 'en',
|
||||
version: 3,
|
||||
});
|
||||
expect(values.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('already-installed catalog slug => skipped (no insert)', async () => {
|
||||
const existing = [
|
||||
makeRow({
|
||||
id: 'r-existing',
|
||||
name: 'Old researcher',
|
||||
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||
}),
|
||||
];
|
||||
const { service, repo } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('same slug installed in a DIFFERENT language => NOT skipped (separate install)', async () => {
|
||||
// Installed as `ru`; importing the `en` variant of the same slug must
|
||||
// still import (dedup key is slug+language, matching the client UI).
|
||||
const existing = [
|
||||
makeRow({
|
||||
id: 'r-ru',
|
||||
name: 'Исследователь',
|
||||
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
|
||||
}),
|
||||
];
|
||||
const { service, repo } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 0 });
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
expect(repo.insert.mock.calls[0][0].source).toEqual({
|
||||
slug: 'researcher',
|
||||
language: 'en',
|
||||
version: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('name collision + conflict:skip => skipped (no insert)', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service, repo } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'skip' }),
|
||||
);
|
||||
expect(res).toMatchObject({ created: 0, skipped: 1, renamed: 0 });
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('name collision + conflict:rename => inserts under " (2)"', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service, repo } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'rename' }),
|
||||
);
|
||||
expect(res).toMatchObject({ created: 1, skipped: 0, renamed: 1 });
|
||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||
});
|
||||
|
||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [catalogRole()],
|
||||
});
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ slugs: ['researcher', 'ghost'] }),
|
||||
);
|
||||
expect(res.created).toBe(1);
|
||||
expect(res.errors).toEqual([
|
||||
{ slug: 'ghost', message: 'Role not found in catalog bundle' },
|
||||
]);
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('insert unique-violation (23505) is recorded as an error, import continues', async () => {
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [
|
||||
catalogRole({ slug: 'a', name: 'A' }),
|
||||
catalogRole({ slug: 'b', name: 'B' }),
|
||||
],
|
||||
indexRoles: [
|
||||
{ slug: 'a', version: 1 },
|
||||
{ slug: 'b', version: 1 },
|
||||
],
|
||||
});
|
||||
repo.insert
|
||||
.mockRejectedValueOnce({ code: '23505' })
|
||||
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res.created).toBe(1);
|
||||
expect(res.errors).toEqual([
|
||||
{ slug: 'a', message: 'A role with this name already exists' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('source-uniqueness 23505 (concurrent import of same slug+language) => skipped, NOT an error, batch continues', async () => {
|
||||
// Two parallel imports of the same bundle each build installedKeys from a
|
||||
// stale snapshot, so both reach the insert for slug 'a'. The DB partial
|
||||
// unique index on (workspace, source->>slug, source->>language) rejects the
|
||||
// loser with a 23505 carrying the source-index constraint name. That must
|
||||
// be treated as "already installed" (skip), not a per-role error, and the
|
||||
// rest of the batch (slug 'b') must still import.
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [
|
||||
catalogRole({ slug: 'a', name: 'A' }),
|
||||
catalogRole({ slug: 'b', name: 'B' }),
|
||||
],
|
||||
indexRoles: [
|
||||
{ slug: 'a', version: 1 },
|
||||
{ slug: 'b', version: 1 },
|
||||
],
|
||||
});
|
||||
// The kysely-postgres-js driver surfaces the violated constraint on
|
||||
// `constraint_name` (not node-postgres' `.constraint`), matching prod.
|
||||
const sourceRace = Object.assign(new Error('duplicate key'), {
|
||||
code: '23505',
|
||||
constraint_name: 'ai_agent_roles_workspace_source_unique',
|
||||
});
|
||||
repo.insert
|
||||
.mockRejectedValueOnce(sourceRace)
|
||||
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||
expect(res.errors).toEqual([]);
|
||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('non-unique insert error => generic message, root cause logged, import continues', async () => {
|
||||
const logSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [
|
||||
catalogRole({ slug: 'a', name: 'A' }),
|
||||
catalogRole({ slug: 'b', name: 'B' }),
|
||||
],
|
||||
indexRoles: [
|
||||
{ slug: 'a', version: 1 },
|
||||
{ slug: 'b', version: 1 },
|
||||
],
|
||||
});
|
||||
// A non-23505 failure (e.g. a not-null violation) on the first insert.
|
||||
const boom = Object.assign(new Error('null value in column'), {
|
||||
code: '23502',
|
||||
});
|
||||
repo.insert
|
||||
.mockRejectedValueOnce(boom)
|
||||
.mockImplementationOnce((v) => Promise.resolve(makeRow(v)));
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
// The generic (non-409) user-facing message; the second role still imports.
|
||||
expect(res.created).toBe(1);
|
||||
expect(res.errors).toEqual([
|
||||
{ slug: 'a', message: 'Failed to import role' },
|
||||
]);
|
||||
// The root cause was logged with the slug for diagnosis.
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(logSpy.mock.calls[0][0])).toContain('slug=a');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('bundleId absent from the index => BadGateway (no insert)', async () => {
|
||||
// The requested bundle is not listed in the fetched index (a stale client
|
||||
// or an index/bundle drift); the import must surface a 502 rather than
|
||||
// silently doing nothing or dereferencing a missing meta.
|
||||
const { service, repo } = makeImportService({});
|
||||
await expect(
|
||||
service.importFromCatalog('ws-1', 'u1', dto({ bundleId: 'missing' })),
|
||||
).rejects.toBeInstanceOf(BadGatewayException);
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFromCatalog', () => {
|
||||
function makeUpdateService(opts: {
|
||||
role?: AiAgentRole;
|
||||
indexBundles?: unknown[];
|
||||
bundleRoles?: Record<string, unknown>[];
|
||||
others?: AiAgentRole[];
|
||||
}) {
|
||||
const index = {
|
||||
schemaVersion: 1,
|
||||
bundles: opts.indexBundles ?? [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 5 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const bundle = {
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: opts.bundleRoles ?? [
|
||||
{ slug: 'researcher', name: 'Researcher v5', instructions: 'new' },
|
||||
],
|
||||
};
|
||||
const repo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.role),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
softDelete: jest.fn(),
|
||||
listByWorkspace: jest.fn().mockResolvedValue(opts.others ?? []),
|
||||
};
|
||||
const catalog = {
|
||||
fetchIndex: jest.fn().mockResolvedValue(index),
|
||||
fetchBundle: jest.fn().mockResolvedValue(bundle),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||
return { service, repo, catalog };
|
||||
}
|
||||
|
||||
const imported = (version: number, over: Partial<AiAgentRole> = {}) =>
|
||||
makeRow({
|
||||
id: 'r1',
|
||||
name: 'Researcher',
|
||||
source: { slug: 'researcher', language: 'en', version } as never,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('role not imported from catalog (source null) => BadRequest', async () => {
|
||||
const { service } = makeUpdateService({ role: makeRow({ source: null }) });
|
||||
await expect(
|
||||
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('role not found => BadRequest', async () => {
|
||||
const { service } = makeUpdateService({ role: undefined });
|
||||
await expect(
|
||||
service.updateFromCatalog('ws-1', { id: 'r1' } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('catalog version <= source.version => up-to-date (no update)', async () => {
|
||||
const { service, repo } = makeUpdateService({ role: imported(5) });
|
||||
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
expect(res).toEqual({ updated: false, reason: 'up-to-date' });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('slug no longer listed in any bundle => not-in-catalog', async () => {
|
||||
const { service, repo } = makeUpdateService({
|
||||
role: imported(1),
|
||||
indexBundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'other', version: 9 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('source.language no longer offered by the bundle => language-unavailable', async () => {
|
||||
const { service, repo } = makeUpdateService({
|
||||
role: imported(1, {
|
||||
source: { slug: 'researcher', language: 'ru', version: 1 } as never,
|
||||
}),
|
||||
indexBundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 5 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
expect(res).toEqual({ updated: false, reason: 'language-unavailable' });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('newer version => updates content + bumps source.version, returns versions', async () => {
|
||||
const role = imported(1);
|
||||
const { service, repo } = makeUpdateService({ role });
|
||||
// The post-update re-fetch returns the bumped row.
|
||||
repo.findById
|
||||
.mockResolvedValueOnce(role)
|
||||
.mockResolvedValueOnce(
|
||||
imported(5, { name: 'Researcher v5', instructions: 'new' }),
|
||||
);
|
||||
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
expect(res).toMatchObject({
|
||||
updated: true,
|
||||
fromVersion: 1,
|
||||
toVersion: 5,
|
||||
});
|
||||
const patch = repo.update.mock.calls[0][2];
|
||||
expect(patch.source).toEqual({
|
||||
slug: 'researcher',
|
||||
language: 'en',
|
||||
version: 5,
|
||||
});
|
||||
expect(patch.name).toBe('Researcher v5');
|
||||
// enabled is never touched by an update-from-catalog.
|
||||
expect('enabled' in patch).toBe(false);
|
||||
});
|
||||
|
||||
it('slug listed in the index but missing from the bundle file => not-in-catalog', async () => {
|
||||
// Index/bundle drift: the index still advertises a newer `researcher`
|
||||
// (v5 > installed v1) in an offered language, but the fetched bundle file
|
||||
// no longer contains that slug. The update must no-op as not-in-catalog,
|
||||
// not throw or write a half-resolved role.
|
||||
const { service, repo } = makeUpdateService({
|
||||
role: imported(1),
|
||||
bundleRoles: [
|
||||
{ slug: 'someone-else', name: 'Other', instructions: 'x' },
|
||||
],
|
||||
});
|
||||
const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
expect(res).toEqual({ updated: false, reason: 'not-in-catalog' });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('new catalog name collides with another live role => keeps current name', async () => {
|
||||
const role = imported(1);
|
||||
const other = makeRow({ id: 'r2', name: 'Researcher v5' });
|
||||
const { service, repo } = makeUpdateService({ role, others: [role, other] });
|
||||
repo.findById
|
||||
.mockResolvedValueOnce(role)
|
||||
.mockResolvedValueOnce(imported(5));
|
||||
await service.updateFromCatalog('ws-1', { id: 'r1' } as never);
|
||||
// The colliding catalog name is dropped; the current name is kept.
|
||||
expect(repo.update.mock.calls[0][2].name).toBe('Researcher');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog browse (getCatalog / getCatalogBundle) against a MOCKED provider.
|
||||
// Covers the localized() three-tier fallback (requested lang -> en -> first ->
|
||||
// null), the sorted union of bundle languages, the missing-bundle BadGateway,
|
||||
// and the role-version default.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getCatalog', () => {
|
||||
function makeBrowseService(index: unknown) {
|
||||
const repo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
listByWorkspace: jest.fn(),
|
||||
};
|
||||
const catalog = {
|
||||
fetchIndex: jest.fn().mockResolvedValue(index),
|
||||
fetchBundle: jest.fn(),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||
return { service, catalog };
|
||||
}
|
||||
|
||||
it('returns the sorted union of every bundle language', async () => {
|
||||
const { service } = makeBrowseService({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'a',
|
||||
name: { en: 'A' },
|
||||
languages: ['ru', 'en'],
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: { en: 'B' },
|
||||
languages: ['en', 'de'],
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.getCatalog('en');
|
||||
expect(res.languages).toEqual(['de', 'en', 'ru']);
|
||||
});
|
||||
|
||||
it('localized name uses the requested language when present', async () => {
|
||||
const { service } = makeBrowseService({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'a',
|
||||
name: { en: 'General', ru: 'Общие' },
|
||||
description: { en: 'desc-en', ru: 'desc-ru' },
|
||||
languages: ['en', 'ru'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.getCatalog('ru');
|
||||
expect(res.bundles[0]).toMatchObject({
|
||||
id: 'a',
|
||||
name: 'Общие',
|
||||
description: 'desc-ru',
|
||||
languages: ['en', 'ru'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('localized name falls back to en when the requested language is missing', async () => {
|
||||
const { service } = makeBrowseService({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'a',
|
||||
name: { en: 'General', ru: 'Общие' },
|
||||
languages: ['en', 'ru'],
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.getCatalog('fr');
|
||||
expect(res.bundles[0].name).toBe('General');
|
||||
});
|
||||
|
||||
it('localized name falls back to the first available locale when en is absent', async () => {
|
||||
const { service } = makeBrowseService({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'a',
|
||||
name: { ru: 'Общие', de: 'Allgemein' },
|
||||
languages: ['ru', 'de'],
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.getCatalog('fr');
|
||||
// Neither 'fr' nor 'en' is present -> first available value.
|
||||
expect(res.bundles[0].name).toBe('Общие');
|
||||
});
|
||||
|
||||
it('empty name map => falls back to the bundle id; absent description => null', async () => {
|
||||
const { service } = makeBrowseService({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'a',
|
||||
name: {},
|
||||
languages: ['en'],
|
||||
roles: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await service.getCatalog('en');
|
||||
expect(res.bundles[0].name).toBe('a');
|
||||
expect(res.bundles[0].description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCatalogBundle', () => {
|
||||
function makeBundleService(opts: {
|
||||
index: unknown;
|
||||
bundle: unknown;
|
||||
}) {
|
||||
const repo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
listByWorkspace: jest.fn(),
|
||||
};
|
||||
const catalog = {
|
||||
fetchIndex: jest.fn().mockResolvedValue(opts.index),
|
||||
fetchBundle: jest.fn().mockResolvedValue(opts.bundle),
|
||||
};
|
||||
const service = new AiAgentRolesService(repo as never, catalog as never);
|
||||
return { service, catalog };
|
||||
}
|
||||
|
||||
const index = {
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 4 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('missing bundle in the index => BadGateway', async () => {
|
||||
const { service, catalog } = makeBundleService({
|
||||
index,
|
||||
bundle: { schemaVersion: 1, language: 'en', roles: [] },
|
||||
});
|
||||
await expect(
|
||||
service.getCatalogBundle('ghost', 'en'),
|
||||
).rejects.toBeInstanceOf(BadGatewayException);
|
||||
expect(catalog.fetchBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps role content with the version taken from the index', async () => {
|
||||
const { service } = makeBundleService({
|
||||
index,
|
||||
bundle: {
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: [
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
instructions: 'be a researcher',
|
||||
emoji: '🔬',
|
||||
autoStart: false,
|
||||
launchMessage: 'go',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = await service.getCatalogBundle('general', 'en');
|
||||
expect(res).toMatchObject({ bundleId: 'general', language: 'en' });
|
||||
expect(res.roles[0]).toEqual({
|
||||
slug: 'researcher',
|
||||
emoji: '🔬',
|
||||
name: 'Researcher',
|
||||
description: null,
|
||||
instructions: 'be a researcher',
|
||||
autoStart: false,
|
||||
launchMessage: 'go',
|
||||
version: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('role absent from the index meta => version defaults to 1; autoStart defaults to true', async () => {
|
||||
const { service } = makeBundleService({
|
||||
index,
|
||||
bundle: {
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: [
|
||||
{ slug: 'newcomer', name: 'Newcomer', instructions: 'hi' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = await service.getCatalogBundle('general', 'en');
|
||||
expect(res.roles[0]).toMatchObject({
|
||||
slug: 'newcomer',
|
||||
version: 1,
|
||||
autoStart: true,
|
||||
emoji: null,
|
||||
launchMessage: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
AiAgentRoleRepo,
|
||||
parseSource,
|
||||
} from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { AiAgentRole, RoleSource } from '@docmost/db/types/entity.types';
|
||||
import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto';
|
||||
import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto';
|
||||
import { RoleModelConfig } from './role-model-config';
|
||||
import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider';
|
||||
import {
|
||||
CatalogBundleFile,
|
||||
CatalogBundleMeta,
|
||||
CatalogRole,
|
||||
} from './catalog/catalog-types';
|
||||
|
||||
/**
|
||||
* Full (admin) view of an agent role. There are no secret columns on this table
|
||||
@@ -24,6 +36,10 @@ export interface AgentRoleView {
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
// Catalog origin of an imported role, or null for a manually-created one. The
|
||||
// admin UI uses `version` to offer an UPDATE when the catalog ships a newer
|
||||
// revision. Admin-only (deliberately absent from AgentRolePickerView).
|
||||
source: RoleSource | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -56,7 +72,12 @@ export interface AgentRolePickerView {
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiAgentRolesService {
|
||||
constructor(private readonly repo: AiAgentRoleRepo) {}
|
||||
private readonly logger = new Logger(AiAgentRolesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: AiAgentRoleRepo,
|
||||
private readonly catalog: AiAgentRolesCatalogProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List the workspace's roles. Admins get the full view (the settings page needs
|
||||
@@ -165,6 +186,316 @@ export class AiAgentRolesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Catalog (admin-only). The catalog is curated, untrusted JSON fetched +
|
||||
// validated by AiAgentRolesCatalogProvider; this layer resolves localized
|
||||
// text and reconciles a bundle against the workspace's existing roles.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Browse the catalog. Returns the union of every bundle's languages (sorted)
|
||||
* plus per-bundle metadata with `name` / `description` resolved to the
|
||||
* requested `language` (fallback: 'en', then the first available locale).
|
||||
*/
|
||||
async getCatalog(language?: string): Promise<{
|
||||
languages: string[];
|
||||
bundles: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}[];
|
||||
}> {
|
||||
const index = await this.catalog.fetchIndex();
|
||||
const languages = Array.from(
|
||||
new Set(index.bundles.flatMap((b) => b.languages)),
|
||||
).sort();
|
||||
const bundles = index.bundles.map((b) => ({
|
||||
id: b.id,
|
||||
name: localized(b.name, language) ?? b.id,
|
||||
description: b.description ? localized(b.description, language) : null,
|
||||
languages: b.languages,
|
||||
roles: b.roles.map((r) => ({ slug: r.slug, version: r.version })),
|
||||
}));
|
||||
return { languages, bundles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared read prefix for the two bundle-by-id catalog paths (getCatalogBundle /
|
||||
* importFromCatalog): fetch the index, resolve the requested bundle's meta
|
||||
* (502 if the index does not list it), fetch its per-language file, and build
|
||||
* the slug->version map from the meta. The callers keep their own response /
|
||||
* write logic; only this duplicated read is factored out here.
|
||||
*/
|
||||
private async loadBundleById(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<{
|
||||
meta: CatalogBundleMeta;
|
||||
file: CatalogBundleFile;
|
||||
versions: Map<string, number>;
|
||||
}> {
|
||||
const index = await this.catalog.fetchIndex();
|
||||
const meta = index.bundles.find((b) => b.id === bundleId);
|
||||
if (!meta) {
|
||||
throw new BadGatewayException('Catalog bundle not found');
|
||||
}
|
||||
const file = await this.catalog.fetchBundle(bundleId, language);
|
||||
return { meta, file, versions: versionMap(meta) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one bundle in a language: returns each role's content plus the version
|
||||
* taken from the index (so the client can compare against an imported role's
|
||||
* source.version). A missing bundle/language => BadGateway (catalog issue).
|
||||
*/
|
||||
async getCatalogBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<{
|
||||
bundleId: string;
|
||||
language: string;
|
||||
roles: {
|
||||
slug: string;
|
||||
emoji: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
version: number;
|
||||
}[];
|
||||
}> {
|
||||
const { file, versions } = await this.loadBundleById(bundleId, language);
|
||||
return {
|
||||
bundleId,
|
||||
language,
|
||||
roles: file.roles.map((r) => ({
|
||||
slug: r.slug,
|
||||
emoji: r.emoji ?? null,
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
instructions: r.instructions,
|
||||
autoStart: r.autoStart ?? true,
|
||||
launchMessage: r.launchMessage ?? null,
|
||||
version: versions.get(r.slug) ?? 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle's roles into the workspace. A role is "already installed"
|
||||
* (and thus skipped — updates are a separate action) only when an existing
|
||||
* role matches BOTH its `source.slug` AND `source.language`: this is a
|
||||
* multilingual catalog, so a different language of the same slug (e.g. the
|
||||
* `ru` variant of a slug already installed as `en`) is a SEPARATE install and
|
||||
* still imports. A name collision with an existing role is either skipped or
|
||||
* imported under a free " (N)" name, per `dto.conflict`. Inserts run
|
||||
* sequentially (the repo exposes no batch insert and the volume is tiny); a
|
||||
* unique-name race still surfaces as an error entry rather than aborting the
|
||||
* whole import.
|
||||
*/
|
||||
async importFromCatalog(
|
||||
workspaceId: string,
|
||||
creatorId: string,
|
||||
dto: ImportFromCatalogDto,
|
||||
): Promise<{
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
}> {
|
||||
const { file, versions } = await this.loadBundleById(
|
||||
dto.bundleId,
|
||||
dto.language,
|
||||
);
|
||||
|
||||
const errors: { slug: string; message: string }[] = [];
|
||||
|
||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||
let selected = file.roles;
|
||||
if (dto.slugs && dto.slugs.length > 0) {
|
||||
const wanted = new Set(dto.slugs);
|
||||
const present = new Set(file.roles.map((r) => r.slug));
|
||||
for (const slug of dto.slugs) {
|
||||
if (!present.has(slug)) {
|
||||
errors.push({ slug, message: 'Role not found in catalog bundle' });
|
||||
}
|
||||
}
|
||||
selected = file.roles.filter((r) => wanted.has(r.slug));
|
||||
}
|
||||
|
||||
const existingRoles = await this.repo.listByWorkspace(workspaceId);
|
||||
// Catalog roles already installed in this workspace, keyed by slug+language
|
||||
// (skip; never duplicate). The key MUST match the client install-state and
|
||||
// updateFromCatalog (both match by source.slug AND source.language): the
|
||||
// `ru` variant of a slug already installed as `en` is a separate install.
|
||||
const installedKeys = new Set(
|
||||
existingRoles
|
||||
.map((r) => parseSource(r.source))
|
||||
.filter((s): s is RoleSource => s !== null)
|
||||
.map((s) => `${s.slug}:${s.language}`),
|
||||
);
|
||||
// Live role names (lowercased) for collision detection. Mutated as we
|
||||
// insert so two imported roles cannot both grab the same name.
|
||||
const takenNames = new Set(
|
||||
existingRoles.map((r) => r.name.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let renamed = 0;
|
||||
|
||||
for (const role of selected) {
|
||||
// Already installed from the catalog in THIS language => skip (use
|
||||
// update-from-catalog). A different language of the same slug still imports.
|
||||
const installKey = `${role.slug}:${dto.language}`;
|
||||
if (installedKeys.has(installKey)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = role.name.trim();
|
||||
let didRename = false;
|
||||
if (takenNames.has(name.toLowerCase())) {
|
||||
if (dto.conflict === 'skip') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// conflict === 'rename': find a free " (N)" suffix.
|
||||
name = freeName(name, takenNames);
|
||||
didRename = true;
|
||||
}
|
||||
|
||||
const version = versions.get(role.slug) ?? 1;
|
||||
try {
|
||||
await this.repo.insert({
|
||||
workspaceId,
|
||||
creatorId,
|
||||
name,
|
||||
...catalogRoleContentFields(role),
|
||||
enabled: true,
|
||||
source: { slug: role.slug, language: dto.language, version },
|
||||
});
|
||||
created++;
|
||||
if (didRename) renamed++;
|
||||
takenNames.add(name.toLowerCase());
|
||||
installedKeys.add(installKey);
|
||||
} catch (err) {
|
||||
// A 23505 from the source-uniqueness index means a CONCURRENT import
|
||||
// already installed this exact slug+language between our snapshot
|
||||
// (installedKeys) and this insert: the in-process snapshot cannot see a
|
||||
// sibling request's writes, so the partial unique index is the backstop.
|
||||
// Outcome is identical to the snapshot-based skip above — count it as
|
||||
// skipped (already installed) and continue; do NOT abort or error.
|
||||
if (isSourceUniqueViolation(err)) {
|
||||
skipped++;
|
||||
installedKeys.add(installKey);
|
||||
continue;
|
||||
}
|
||||
// Otherwise: a unique-NAME race (23505 on the name index) is expected and
|
||||
// self-explanatory (it becomes a friendly per-role error). Any OTHER
|
||||
// insert failure is unexpected, so log the root cause with enough context
|
||||
// to diagnose it — the user-facing message is deliberately generic.
|
||||
if (!isUniqueViolation(err)) {
|
||||
this.logger.error(
|
||||
`Failed to import catalog role (workspaceId=${workspaceId} bundleId=${dto.bundleId} slug=${role.slug}): ${err instanceof Error ? err.stack ?? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
errors.push({ slug: role.slug, message: importErrorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { created, skipped, renamed, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an already-imported role from its catalog source when the catalog
|
||||
* ships a newer version. Returns a discriminated result so the UI can explain
|
||||
* a no-op (up-to-date / removed from catalog / language no longer offered).
|
||||
* Never touches `enabled`; keeps the current name if the catalog's new name
|
||||
* would collide with another role (avoiding the unique-name 409).
|
||||
*/
|
||||
async updateFromCatalog(
|
||||
workspaceId: string,
|
||||
dto: UpdateFromCatalogDto,
|
||||
): Promise<
|
||||
| { updated: false; reason: 'not-in-catalog' | 'up-to-date' | 'language-unavailable' }
|
||||
| { updated: true; fromVersion: number; toVersion: number; role: AgentRoleView }
|
||||
> {
|
||||
const role = await this.repo.findById(dto.id, workspaceId);
|
||||
if (!role) throw new BadRequestException('Role not found');
|
||||
|
||||
const source = parseSource(role.source);
|
||||
if (!source || !source.slug) {
|
||||
throw new BadRequestException('Role was not imported from the catalog');
|
||||
}
|
||||
|
||||
const index = await this.catalog.fetchIndex();
|
||||
// Find the bundle whose meta lists this slug, and its catalog version.
|
||||
let meta: CatalogBundleMeta | undefined;
|
||||
let currentVersion: number | undefined;
|
||||
for (const b of index.bundles) {
|
||||
const m = b.roles.find((r) => r.slug === source.slug);
|
||||
if (m) {
|
||||
meta = b;
|
||||
currentVersion = m.version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!meta || currentVersion === undefined) {
|
||||
return { updated: false, reason: 'not-in-catalog' };
|
||||
}
|
||||
if (currentVersion <= source.version) {
|
||||
return { updated: false, reason: 'up-to-date' };
|
||||
}
|
||||
if (!meta.languages.includes(source.language)) {
|
||||
return { updated: false, reason: 'language-unavailable' };
|
||||
}
|
||||
|
||||
const file = await this.catalog.fetchBundle(meta.id, source.language);
|
||||
const fresh = file.roles.find((r) => r.slug === source.slug);
|
||||
if (!fresh) {
|
||||
return { updated: false, reason: 'not-in-catalog' };
|
||||
}
|
||||
|
||||
// Keep the current name when the catalog's new name would collide with
|
||||
// another live role (avoids the unique-name 409). Same-name (case-insensitive)
|
||||
// means "no rename needed".
|
||||
const newName = fresh.name.trim();
|
||||
let name = newName;
|
||||
if (newName.toLowerCase() !== role.name.trim().toLowerCase()) {
|
||||
const others = await this.repo.listByWorkspace(workspaceId);
|
||||
const collision = others.some(
|
||||
(r) =>
|
||||
r.id !== role.id &&
|
||||
r.name.trim().toLowerCase() === newName.toLowerCase(),
|
||||
);
|
||||
if (collision) name = role.name;
|
||||
}
|
||||
|
||||
await this.repo.update(dto.id, workspaceId, {
|
||||
name,
|
||||
...catalogRoleContentFields(fresh),
|
||||
// enabled is deliberately NOT changed.
|
||||
source: {
|
||||
slug: source.slug,
|
||||
language: source.language,
|
||||
version: currentVersion,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await this.repo.findById(dto.id, workspaceId);
|
||||
if (!updated) throw new BadRequestException('Role not found');
|
||||
return {
|
||||
updated: true,
|
||||
fromVersion: source.version,
|
||||
toVersion: currentVersion,
|
||||
role: this.toView(updated),
|
||||
};
|
||||
}
|
||||
|
||||
private toView(row: AiAgentRole): AgentRoleView {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -176,6 +507,9 @@ export class AiAgentRolesService {
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
// parseSource yields a fully-valid RoleSource | null (the row is already
|
||||
// normalized; this also keeps the field type honest without a cast).
|
||||
source: parseSource(row.source),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -205,11 +539,7 @@ export class AiAgentRolesService {
|
||||
* failures keep surfacing as 500s.
|
||||
*/
|
||||
function rethrowDuplicateName(err: unknown, name: string): never {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
(err as { code?: unknown }).code === '23505'
|
||||
) {
|
||||
if (isUniqueViolation(err)) {
|
||||
throw new ConflictException(
|
||||
`A role named "${name}" already exists in this workspace.`,
|
||||
);
|
||||
@@ -217,13 +547,120 @@ function rethrowDuplicateName(err: unknown, name: string): never {
|
||||
throw err;
|
||||
}
|
||||
|
||||
/** '' / whitespace-only / undefined => null; otherwise the trimmed value. */
|
||||
function emptyToNull(value: string | undefined): string | null {
|
||||
if (value === undefined) return null;
|
||||
/** Whether `err` is a Postgres unique-violation (SQLSTATE 23505). */
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
(err as { code?: unknown }).code === '23505'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The partial unique index name from the
|
||||
* 20260626T160000-ai-agent-roles-catalog-source-unique migration: unique on
|
||||
* (workspace_id, source->>'slug', source->>'language') for catalog-imported,
|
||||
* non-deleted rows. A 23505 carrying this constraint name is a source-collision
|
||||
* (concurrent import of the same slug+language), distinct from a name-collision.
|
||||
*/
|
||||
const SOURCE_UNIQUE_CONSTRAINT = 'ai_agent_roles_workspace_source_unique';
|
||||
|
||||
/**
|
||||
* Whether `err` is the 23505 raised by the SOURCE-uniqueness index specifically
|
||||
* (vs the name-uniqueness index). The active driver (`kysely-postgres-js` over
|
||||
* `postgres@3.4.8`) exposes the violated constraint name on `constraint_name`,
|
||||
* so we key off that (accepting the node-postgres-style `.constraint` as a
|
||||
* fallback for other drivers) — that way a source race is skipped while a name
|
||||
* race still surfaces as a friendly per-role error. A 23505 with no constraint
|
||||
* name (e.g. a wrapped/test error) is NOT treated as a source collision,
|
||||
* preserving the existing name-race behavior.
|
||||
*/
|
||||
function isSourceUniqueViolation(err: unknown): boolean {
|
||||
if (!isUniqueViolation(err)) return false;
|
||||
const e = err as { constraint_name?: unknown; constraint?: unknown };
|
||||
return (
|
||||
e.constraint_name === SOURCE_UNIQUE_CONSTRAINT ||
|
||||
e.constraint === SOURCE_UNIQUE_CONSTRAINT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The role-content fields shared by import (insert) and update (patch) of a
|
||||
* catalog role: emoji/description/launchMessage normalized to null, model config
|
||||
* normalized, autoStart defaulted. The caller adds the write-specific fields
|
||||
* (`name`, `source`, and on insert `workspaceId`/`creatorId`/`enabled`).
|
||||
*/
|
||||
function catalogRoleContentFields(role: CatalogRole): {
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
modelConfig: Record<string, unknown> | null;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
} {
|
||||
return {
|
||||
emoji: emptyToNull(role.emoji),
|
||||
description: emptyToNull(role.description),
|
||||
instructions: role.instructions,
|
||||
modelConfig: normalizeModelConfig(role.modelConfig) as
|
||||
| Record<string, unknown>
|
||||
| null,
|
||||
autoStart: role.autoStart ?? true,
|
||||
launchMessage: emptyToNull(role.launchMessage ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** '' / whitespace-only / undefined / null => null; otherwise the trimmed value. */
|
||||
function emptyToNull(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/** slug -> version map from a bundle's index metadata. */
|
||||
function versionMap(meta: CatalogBundleMeta): Map<string, number> {
|
||||
return new Map(meta.roles.map((r) => [r.slug, r.version]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a localized value `{ en, ru, ... }` to `language`, falling back to
|
||||
* 'en', then the first available locale. Returns null only for an empty map.
|
||||
*/
|
||||
function localized(
|
||||
map: Record<string, string>,
|
||||
language?: string,
|
||||
): string | null {
|
||||
if (language && typeof map[language] === 'string') return map[language];
|
||||
if (typeof map.en === 'string') return map.en;
|
||||
const first = Object.values(map)[0];
|
||||
return typeof first === 'string' ? first : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free display name by appending " (2)", " (3)", ... when `base` is
|
||||
* already taken (case-insensitive against `taken`). Caller adds the result to
|
||||
* `taken` after a successful insert.
|
||||
*/
|
||||
function freeName(base: string, taken: Set<string>): string {
|
||||
// `taken` is finite, so within `taken.size + 2` iterations a candidate index
|
||||
// is guaranteed free; the 1000 cap is a defensive upper bound far above any
|
||||
// realistic per-name collision count. The throw below is therefore
|
||||
// unreachable in practice and only satisfies the return-type checker.
|
||||
for (let n = 2; n < 1000; n++) {
|
||||
const candidate = `${base} (${n})`;
|
||||
if (!taken.has(candidate.toLowerCase())) return candidate;
|
||||
}
|
||||
throw new BadRequestException(`Too many roles named "${base}"`);
|
||||
}
|
||||
|
||||
/** A short, safe message for an import insert failure (409 vs other). */
|
||||
function importErrorMessage(err: unknown): string {
|
||||
if (isUniqueViolation(err)) {
|
||||
return 'A role with this name already exists';
|
||||
}
|
||||
return 'Failed to import role';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an incoming modelConfig DTO to the persisted shape, or null when
|
||||
* there is no usable override (no driver and no chatModel). The DTO's @IsIn
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { BadGatewayException, BadRequestException } from '@nestjs/common';
|
||||
import { AiAgentRolesCatalogProvider } from './ai-agent-roles-catalog.provider';
|
||||
|
||||
/**
|
||||
* Provider tests against a LOCAL fixture directory (no network). They cover the
|
||||
* happy read path (fetchIndex / fetchBundle), the malformed-shape rejection, a
|
||||
* missing file => unavailable, and — most importantly — the `^[a-z0-9-]+$`
|
||||
* path-traversal guard that runs BEFORE any path is built.
|
||||
*/
|
||||
describe('AiAgentRolesCatalogProvider (local fixtures)', () => {
|
||||
let dir: string;
|
||||
|
||||
function makeProvider(source: string) {
|
||||
const env = {
|
||||
getAiAgentRolesCatalogSource: () => source,
|
||||
};
|
||||
return new AiAgentRolesCatalogProvider(env as never);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-roles-catalog-'));
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'index.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General', ru: 'Общие' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
await fs.mkdir(path.join(dir, 'bundles', 'general'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'bundles', 'general', 'en.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'en',
|
||||
roles: [
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
instructions: 'be a researcher',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
// A malformed bundle (a role missing `instructions`) to test rejection.
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'bundles', 'general', 'fr.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
language: 'fr',
|
||||
roles: [{ slug: 'researcher', name: 'Chercheur' }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fetchIndex reads + validates index.json', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const index = await provider.fetchIndex();
|
||||
expect(index.schemaVersion).toBe(1);
|
||||
expect(index.bundles[0].id).toBe('general');
|
||||
expect(index.bundles[0].roles[0]).toEqual({
|
||||
slug: 'researcher',
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('fetchBundle reads + validates a language file', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
const bundle = await provider.fetchBundle('general', 'en');
|
||||
expect(bundle.language).toBe('en');
|
||||
expect(bundle.roles[0].slug).toBe('researcher');
|
||||
expect(bundle.roles[0].instructions).toBe('be a researcher');
|
||||
});
|
||||
|
||||
it('malformed bundle (missing instructions) => BadGateway', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(provider.fetchBundle('general', 'fr')).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('missing file => BadGateway (unavailable)', async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(
|
||||
provider.fetchBundle('general', 'de'),
|
||||
).rejects.toBeInstanceOf(BadGatewayException);
|
||||
});
|
||||
|
||||
it('empty source resolves to the in-repo folder (no throw building the path)', async () => {
|
||||
// With an empty source the provider targets ./agent-roles-catalog under the
|
||||
// cwd; that folder is created by a separate task, so a read here surfaces as
|
||||
// BadGateway (unavailable) rather than a path-build error.
|
||||
const provider = makeProvider('');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
describe('remote fetch streaming size cap', () => {
|
||||
const realFetch = global.fetch;
|
||||
afterEach(() => {
|
||||
global.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** A web ReadableStream that yields `chunks` (each a Uint8Array). */
|
||||
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (i < chunks.length) controller.enqueue(chunks[i++]);
|
||||
else controller.close();
|
||||
},
|
||||
// The provider cancels the reader on the too-large path; no-op here.
|
||||
cancel() {},
|
||||
});
|
||||
}
|
||||
|
||||
/** A ReadableStream whose first read rejects (e.g. a mid-body AbortError). */
|
||||
function errorStream(err: Error): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
throw err;
|
||||
},
|
||||
cancel() {},
|
||||
});
|
||||
}
|
||||
|
||||
function mockResponse(opts: {
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
text?: string;
|
||||
}): Response {
|
||||
return {
|
||||
ok: opts.ok ?? true,
|
||||
status: opts.status ?? 200,
|
||||
headers: { get: (k: string) => opts.headers?.[k.toLowerCase()] ?? null },
|
||||
body: opts.body,
|
||||
text: async () => opts.text ?? 'unused',
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
it('declared Content-Length over the cap => BadGateway before reading the body', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
mockResponse({
|
||||
headers: { 'content-length': String(2_000_000) },
|
||||
body: streamOf([new Uint8Array(10)]),
|
||||
}),
|
||||
) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('streamed body exceeding the cap (no/under-reported Content-Length) => BadGateway', async () => {
|
||||
// 1.5 MB streamed in 256 KB chunks, with no Content-Length header.
|
||||
const chunks = Array.from(
|
||||
{ length: 6 },
|
||||
() => new Uint8Array(256 * 1024),
|
||||
);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body: streamOf(chunks) })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('fetch rejects (network failure) => BadGateway (unavailable)', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('ECONNREFUSED')) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes redirect:"error" to fetch (redirect-SSRF hardening)', async () => {
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mockResponse({ body: streamOf([new Uint8Array(0)]) }),
|
||||
);
|
||||
global.fetch = fetchMock as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
// Body shape is irrelevant; an empty stream parses to invalid JSON and
|
||||
// throws, but the fetch call (with its init) still happened.
|
||||
await expect(provider.fetchIndex()).rejects.toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('redirect response rejects (redirect:"error") => BadGateway', async () => {
|
||||
// With redirect:"error", the platform fetch rejects on a 3xx instead of
|
||||
// following it. Simulate that: the mock rejects when asked not to follow.
|
||||
global.fetch = jest.fn().mockImplementation((_url, init) => {
|
||||
if (init?.redirect === 'error') {
|
||||
return Promise.reject(
|
||||
new TypeError('fetch failed: unexpected redirect'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
mockResponse({ status: 302, body: null }),
|
||||
);
|
||||
}) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('non-ok response (503) => BadGateway carrying the status', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue(
|
||||
mockResponse({ ok: false, status: 503, body: null }),
|
||||
) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toThrow(/503/);
|
||||
});
|
||||
|
||||
it('small streamed body parses normally (cap not hit)', async () => {
|
||||
const json = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const body = streamOf([new TextEncoder().encode(json)]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
const index = await provider.fetchIndex();
|
||||
expect(index.bundles[0].id).toBe('general');
|
||||
});
|
||||
|
||||
it('body read aborts mid-stream (AbortError) => BadGateway (not a generic 500)', async () => {
|
||||
// The 10s timer aborts the whole request; on a slow/dripping source the
|
||||
// body read (reader.read()) rejects with an AbortError AFTER fetch()
|
||||
// resolved. The provider must map that to BadGateway, not let it escape.
|
||||
const abortErr = Object.assign(new Error('The operation was aborted'), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body: errorStream(abortErr) })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('null body (no readable stream) => response.text() fallback parses', async () => {
|
||||
const json = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
bundles: [
|
||||
{
|
||||
id: 'general',
|
||||
name: { en: 'General' },
|
||||
languages: ['en'],
|
||||
roles: [{ slug: 'researcher', version: 2 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body: null, text: json })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
const index = await provider.fetchIndex();
|
||||
expect(index.bundles[0].id).toBe('general');
|
||||
});
|
||||
|
||||
it('null body + text() over the cap => BadGateway (too large)', async () => {
|
||||
const oversized = 'a'.repeat(1_000_001);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mockResponse({ body: null, text: oversized }),
|
||||
) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid JSON body => BadGateway (parse failure)', async () => {
|
||||
const body = streamOf([new TextEncoder().encode('{not valid json')]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toBeInstanceOf(
|
||||
BadGatewayException,
|
||||
);
|
||||
});
|
||||
|
||||
it('malformed index.json (valid JSON, wrong shape) => BadGateway', async () => {
|
||||
// Parses as JSON but fails isCatalogIndex (schemaVersion not a number).
|
||||
const body = streamOf([
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ schemaVersion: 'x', bundles: [] }),
|
||||
),
|
||||
]);
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockResponse({ body })) as never;
|
||||
const provider = makeProvider('https://catalog.example.com');
|
||||
await expect(provider.fetchIndex()).rejects.toThrow(/malformed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path-traversal / SSRF guard (^[a-z0-9-]+$)', () => {
|
||||
const bad = ['../etc', 'a/b', 'A', 'foo.bar', 'foo_bar', '', '..'];
|
||||
|
||||
for (const value of bad) {
|
||||
it(`rejects bundleId="${value}" with BadRequest`, async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(
|
||||
provider.fetchBundle(value, 'en'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it(`rejects language="${value}" with BadRequest`, async () => {
|
||||
const provider = makeProvider(dir);
|
||||
await expect(
|
||||
provider.fetchBundle('general', value),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { EnvironmentService } from '../../../../integrations/environment/environment.service';
|
||||
import {
|
||||
CatalogBundleFile,
|
||||
CatalogBundleMeta,
|
||||
CatalogIndex,
|
||||
CatalogRole,
|
||||
} from './catalog-types';
|
||||
|
||||
/** Identifier shape allowed in any path/URL segment (bundleId, language). The
|
||||
* ONLY characters that can appear in a fetched path — the path-traversal and
|
||||
* SSRF guard. Anything else is rejected before a path/URL is built. */
|
||||
const SEGMENT_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
/** Remote fetch timeout and response-size cap. A curated catalog file is tiny;
|
||||
* the cap stops a hostile/misconfigured source from streaming unbounded data. */
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* Fetches + validates the agent-roles catalog from its configured source. The
|
||||
* source location (EnvironmentService.getAiAgentRolesCatalogSource()) is either
|
||||
* an http(s):// base URL (REMOTE) or a local filesystem directory (LOCAL; the
|
||||
* empty default resolves to the in-repo `agent-roles-catalog/` folder).
|
||||
*
|
||||
* The catalog is UNTRUSTED input: every file is JSON-parsed and run through a
|
||||
* hand-written type guard before any field is exposed, and every dynamic path
|
||||
* segment is validated against SEGMENT_RE up front (path-traversal + SSRF).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiAgentRolesCatalogProvider {
|
||||
private readonly logger = new Logger(AiAgentRolesCatalogProvider.name);
|
||||
|
||||
constructor(private readonly environmentService: EnvironmentService) {}
|
||||
|
||||
/** Read + validate the top-level index (`index.json`). */
|
||||
async fetchIndex(): Promise<CatalogIndex> {
|
||||
const raw = await this.readRelative('index.json');
|
||||
const parsed = this.parseJson(raw, 'index.json');
|
||||
if (!isCatalogIndex(parsed)) {
|
||||
throw new BadGatewayException(
|
||||
'Agent roles catalog index is malformed (index.json)',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Read + validate one language file (`bundles/<bundleId>/<language>.json`). */
|
||||
async fetchBundle(
|
||||
bundleId: string,
|
||||
language: string,
|
||||
): Promise<CatalogBundleFile> {
|
||||
// SECURITY: validate BEFORE building any path/URL (path-traversal + SSRF).
|
||||
this.assertSegment(bundleId, 'bundleId');
|
||||
this.assertSegment(language, 'language');
|
||||
const rel = `bundles/${bundleId}/${language}.json`;
|
||||
const raw = await this.readRelative(rel);
|
||||
const parsed = this.parseJson(raw, rel);
|
||||
if (!isCatalogBundleFile(parsed)) {
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog bundle is malformed (${rel})`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Reject a segment that is not a safe `[a-z0-9-]+` identifier. */
|
||||
private assertSegment(value: string, field: string): void {
|
||||
if (typeof value !== 'string' || !SEGMENT_RE.test(value)) {
|
||||
throw new BadRequestException(`Invalid ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON.parse with a clear BadGateway on malformed content. */
|
||||
private parseJson(raw: string, rel: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
const reason = shortError(err);
|
||||
this.logger.error(`Agent roles catalog JSON parse failed (${rel}): ${reason}`);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog file is not valid JSON (${rel}): ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a relative catalog path as text from the configured source. */
|
||||
private async readRelative(rel: string): Promise<string> {
|
||||
const source = this.environmentService
|
||||
.getAiAgentRolesCatalogSource()
|
||||
.trim();
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
return this.fetchRemote(source, rel);
|
||||
}
|
||||
const dir = source || path.join(process.cwd(), 'agent-roles-catalog');
|
||||
return this.readLocal(dir, rel);
|
||||
}
|
||||
|
||||
/** Read a local catalog file. Missing => the catalog is unavailable. */
|
||||
private async readLocal(dir: string, rel: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(path.join(dir, rel), 'utf8');
|
||||
} catch (err) {
|
||||
const reason = shortError(err);
|
||||
this.logger.error(
|
||||
`Agent roles catalog local read failed (${path.join(dir, rel)}): ${reason}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog is unavailable: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote catalog file with a timeout + a STREAMING size cap. The body
|
||||
* is never buffered in full before the check: we reject on a too-large
|
||||
* Content-Length up front, then read the stream chunk-by-chunk and abort the
|
||||
* moment the running total exceeds MAX_BYTES, so a hostile/misconfigured
|
||||
* source cannot make us hold an unbounded body in memory.
|
||||
*/
|
||||
private async fetchRemote(base: string, rel: string): Promise<string> {
|
||||
const url = `${base.replace(/\/+$/, '')}/${rel}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
// `redirect: 'error'` hardens against redirect-SSRF: a
|
||||
// compromised-but-trusted upstream cannot 3xx the fetch into the
|
||||
// internal network (e.g. http://169.254.169.254/...). A redirect
|
||||
// response rejects here and is mapped to BadGateway below.
|
||||
response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = shortError(err);
|
||||
this.logger.error(
|
||||
`Agent roles catalog remote fetch failed (${rel}): ${reason}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog is unavailable: ${reason}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
this.logger.error(
|
||||
`Agent roles catalog remote returned ${response.status} (${rel})`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog returned ${response.status}`,
|
||||
);
|
||||
}
|
||||
// Reject a too-large declared size before reading any body bytes.
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > MAX_BYTES) {
|
||||
throw new BadGatewayException('Agent roles catalog file is too large');
|
||||
}
|
||||
// Bound the actual read: a missing/lying Content-Length is caught here.
|
||||
// The 10s timer aborts the WHOLE request, so a slow/dripping hostile
|
||||
// source rejects reader.read() (or response.text()) with an AbortError
|
||||
// mid-body. Map that — and any other read failure — to a logged
|
||||
// BadGateway so the admin endpoint returns 502 (not a generic 500). The
|
||||
// cap's own BadGateway is rethrown as-is (no double-wrap).
|
||||
try {
|
||||
if (response.body) {
|
||||
return await readStreamCapped(response.body, MAX_BYTES);
|
||||
}
|
||||
// Edge: no readable stream — fall back to a buffered read + length check.
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_BYTES) {
|
||||
throw new BadGatewayException('Agent roles catalog file is too large');
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
if (err instanceof BadGatewayException) throw err;
|
||||
const reason = shortError(err);
|
||||
this.logger.error(
|
||||
`Agent roles catalog body read failed (${rel}): ${reason}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Agent roles catalog is unavailable: ${reason}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a web ReadableStream into a UTF-8 string, throwing as soon as the
|
||||
* accumulated byte count exceeds `maxBytes` (the reader is cancelled so the
|
||||
* underlying connection is released). Never buffers more than the cap + the
|
||||
* final chunk before bailing out.
|
||||
*/
|
||||
async function readStreamCapped(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
throw new BadGatewayException('Agent roles catalog file is too large');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
// Release the stream on both the normal and the too-large/abort paths.
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, non-sensitive error string for logging/propagation: only the first
|
||||
* line of the message head is kept (upstream bodies / URLs are discarded).
|
||||
*/
|
||||
function shortError(err: unknown): string {
|
||||
let message = '';
|
||||
if (typeof err === 'string') {
|
||||
message = err;
|
||||
} else if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
typeof (err as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
// Read `.message` directly (works for Error instances and the realm-shifted
|
||||
// Error-likes jest can hand back, where `instanceof Error` is false).
|
||||
message = (err as { message: string }).message;
|
||||
}
|
||||
const head = (message || 'unknown error').split('\n')[0];
|
||||
return head.length > 200 ? `${head.slice(0, 200)}…` : head;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand-written type guards (no zod / new deps). Each validates the exact wire
|
||||
// shape declared in catalog-types.ts; anything else is rejected by the caller.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isStringMap(v: unknown): v is Record<string, string> {
|
||||
if (!isObject(v)) return false;
|
||||
return Object.values(v).every((x) => typeof x === 'string');
|
||||
}
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
||||
}
|
||||
|
||||
export function isCatalogRole(v: unknown): v is CatalogRole {
|
||||
if (!isObject(v)) return false;
|
||||
if (typeof v.slug !== 'string') return false;
|
||||
if (typeof v.name !== 'string') return false;
|
||||
if (typeof v.instructions !== 'string') return false;
|
||||
if (v.emoji !== undefined && typeof v.emoji !== 'string') return false;
|
||||
if (v.description !== undefined && typeof v.description !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (v.autoStart !== undefined && typeof v.autoStart !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
v.launchMessage !== undefined &&
|
||||
v.launchMessage !== null &&
|
||||
typeof v.launchMessage !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
v.modelConfig !== undefined &&
|
||||
v.modelConfig !== null &&
|
||||
!isObject(v.modelConfig)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isCatalogBundleFile(v: unknown): v is CatalogBundleFile {
|
||||
if (!isObject(v)) return false;
|
||||
if (typeof v.schemaVersion !== 'number') return false;
|
||||
if (typeof v.language !== 'string') return false;
|
||||
if (!Array.isArray(v.roles)) return false;
|
||||
return v.roles.every(isCatalogRole);
|
||||
}
|
||||
|
||||
function isCatalogBundleMeta(v: unknown): v is CatalogBundleMeta {
|
||||
if (!isObject(v)) return false;
|
||||
if (typeof v.id !== 'string') return false;
|
||||
if (!isStringMap(v.name)) return false;
|
||||
if (v.description !== undefined && !isStringMap(v.description)) return false;
|
||||
if (!isStringArray(v.languages)) return false;
|
||||
if (!Array.isArray(v.roles)) return false;
|
||||
return v.roles.every(
|
||||
(r) =>
|
||||
isObject(r) &&
|
||||
typeof r.slug === 'string' &&
|
||||
typeof r.version === 'number',
|
||||
);
|
||||
}
|
||||
|
||||
export function isCatalogIndex(v: unknown): v is CatalogIndex {
|
||||
if (!isObject(v)) return false;
|
||||
if (typeof v.schemaVersion !== 'number') return false;
|
||||
if (!Array.isArray(v.bundles)) return false;
|
||||
return v.bundles.every(isCatalogBundleMeta);
|
||||
}
|
||||
47
apps/server/src/core/ai-chat/roles/catalog/catalog-types.ts
Normal file
47
apps/server/src/core/ai-chat/roles/catalog/catalog-types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Catalog wire shapes. The catalog is curated, untrusted JSON (a GitHub repo or
|
||||
* a local folder), so every shape is validated by a hand-written type guard in
|
||||
* the provider before any field is used — no zod / new deps on the server.
|
||||
*
|
||||
* Localized fields (`name` / `description` at the bundle level) are
|
||||
* `Record<language, string>` so one bundle serves many UI languages; per-role
|
||||
* `name` / `description` are already language-specific (the bundle file is keyed
|
||||
* by language).
|
||||
*/
|
||||
|
||||
/** One role's content as shipped in a per-language bundle file. */
|
||||
export interface CatalogRole {
|
||||
slug: string;
|
||||
emoji?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string | null;
|
||||
// Optional model override; same loose object shape as ai_agent_roles.model_config.
|
||||
modelConfig?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** A single language file: `bundles/<id>/<language>.json`. */
|
||||
export interface CatalogBundleFile {
|
||||
schemaVersion: number;
|
||||
language: string;
|
||||
roles: CatalogRole[];
|
||||
}
|
||||
|
||||
/** Bundle metadata as listed in the top-level index. Versions live here (per
|
||||
* slug), so an UPDATE check needs only the index, not every language file. */
|
||||
export interface CatalogBundleMeta {
|
||||
id: string;
|
||||
// Localized display name/description: { en: '...', ru: '...' }.
|
||||
name: Record<string, string>;
|
||||
description?: Record<string, string>;
|
||||
languages: string[];
|
||||
roles: { slug: string; version: number }[];
|
||||
}
|
||||
|
||||
/** Top-level catalog index: `index.json`. */
|
||||
export interface CatalogIndex {
|
||||
schemaVersion: number;
|
||||
bundles: CatalogBundleMeta[];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Safe identifier shape for any catalog path segment (bundleId / language).
|
||||
* Mirrors SEGMENT_RE in the catalog provider — the path-traversal/SSRF guard
|
||||
* is enforced both at the API boundary (here) and in the provider. */
|
||||
const SEGMENT_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
/** Browse the catalog, optionally localized to `language` (defaults applied in
|
||||
* the service: fall back to 'en', then the first available language). */
|
||||
export class CatalogQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/** Open one catalog bundle in a specific language. */
|
||||
export class CatalogBundleDto {
|
||||
@IsString()
|
||||
@Matches(SEGMENT_RE)
|
||||
bundleId: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(SEGMENT_RE)
|
||||
language: string;
|
||||
}
|
||||
|
||||
/** Import roles from a catalog bundle into the workspace. */
|
||||
export class ImportFromCatalogDto {
|
||||
@IsString()
|
||||
@Matches(SEGMENT_RE)
|
||||
bundleId: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(SEGMENT_RE)
|
||||
language: string;
|
||||
|
||||
// Omitted => import the whole bundle; otherwise only these slugs.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
slugs?: string[];
|
||||
|
||||
// How to handle a name collision with an existing (non-catalog) role:
|
||||
// 'skip' leaves it; 'rename' imports under a free " (N)" name.
|
||||
@IsIn(['skip', 'rename'])
|
||||
conflict: 'skip' | 'rename';
|
||||
}
|
||||
|
||||
/** Update an already-imported role from its catalog source. */
|
||||
export class UpdateFromCatalogDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Default lifetime for a temporary note, in HOURS, used when the workspace has
|
||||
// no `temporaryNoteHours` configured (NULL). Mirrors the trash-cleanup
|
||||
// DEFAULT_RETENTION_DAYS fallback. After this many hours a temporary note is
|
||||
// auto-moved to trash unless it was made permanent first.
|
||||
export const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -32,4 +33,10 @@ export class CreatePageDto {
|
||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
|
||||
// When true, create the page as a temporary note: arm its death timer
|
||||
// (now + workspace temporaryNoteHours) at creation.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { TemporaryNoteCleanupService } from './services/temporary-note-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
@@ -16,6 +17,7 @@ import { LabelModule } from '../label/label.module';
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
TemporaryNoteCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { PageService } from './page.service';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
|
||||
@@ -420,4 +421,79 @@ describe('PageService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create() temporary deadline (#201)', () => {
|
||||
// db stub for the workspaces.temporaryNoteHours lookup:
|
||||
// selectFrom('workspaces').select(['temporaryNoteHours']).where(...).executeTakeFirst()
|
||||
const makeDb = (workspaceRow: any) => {
|
||||
const builder: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||
};
|
||||
return builder;
|
||||
};
|
||||
|
||||
const makeGeneralQueue = () =>
|
||||
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
|
||||
|
||||
const run = async (dto: any, workspaceRow: any) => {
|
||||
const pageRepo = {
|
||||
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
|
||||
};
|
||||
const db = makeDb(workspaceRow);
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
db as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
makeGeneralQueue(), // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
{} as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
// nextPagePosition runs a real db query; stub it out.
|
||||
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
|
||||
await svc.create('u1', 'w1', dto, undefined);
|
||||
return { payload: pageRepo.insertPage.mock.calls[0][0], db };
|
||||
};
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('freezes temporaryExpiresAt at now + workspace hours when temporary', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
const { payload } = await run(
|
||||
{ title: 't', spaceId: 's1', temporary: true },
|
||||
{ temporaryNoteHours: 5 },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toEqual(
|
||||
new Date(Date.now() + 5 * 60 * 60 * 1000),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to DEFAULT_TEMPORARY_NOTE_HOURS when the workspace hours are null', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
const { payload } = await run(
|
||||
{ title: 't', spaceId: 's1', temporary: true },
|
||||
{ temporaryNoteHours: null },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toEqual(
|
||||
new Date(Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves temporaryExpiresAt undefined and skips the workspace lookup for a non-temporary page', async () => {
|
||||
const { payload, db } = await run(
|
||||
{ title: 't', spaceId: 's1' },
|
||||
{ temporaryNoteHours: 5 },
|
||||
);
|
||||
expect(payload.temporaryExpiresAt).toBeUndefined();
|
||||
expect(db.selectFrom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
AuthProvenanceData,
|
||||
agentSourceFields,
|
||||
} from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
// Hard upper bound on how deep the recursive page-tree CTEs (ancestor /
|
||||
// descendant traversals) may walk. Real page trees are only a handful of levels
|
||||
@@ -140,6 +141,20 @@ export class PageService {
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
|
||||
// Freeze the death timer here so later changes to the workspace setting
|
||||
// never reschedule existing temporary notes. NULL => permanent page.
|
||||
let temporaryExpiresAt: Date | undefined;
|
||||
if (createPageDto.temporary) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['temporaryNoteHours'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
const hours =
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
let content = undefined;
|
||||
let textContent = undefined;
|
||||
let ydoc = undefined;
|
||||
@@ -172,6 +187,7 @@ export class PageService {
|
||||
// (creatorId/lastUpdatedById); these only annotate the source. A normal
|
||||
// user request leaves the column default ('user').
|
||||
...agentSourceFields(provenance, 'lastUpdatedSource', 'lastUpdatedAiChatId'),
|
||||
temporaryExpiresAt,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
@@ -356,6 +372,7 @@ export class PageService {
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isTemplate',
|
||||
'temporaryExpiresAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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 (the batch SELECT);
|
||||
* `.executeTakeFirst()` resolves the per-row deadline re-read done just before
|
||||
* each `removePage`. By default the re-read reports the note as still armed and
|
||||
* still expired (epoch deadline < now), so the sweep proceeds to delete it;
|
||||
* tests override `reReadFirst` to simulate a concurrent "Make permanent".
|
||||
*/
|
||||
function makeDbStub(expiredRows: any[]) {
|
||||
const whereCalls: any[][] = [];
|
||||
const reReadFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ temporaryExpiresAt: new Date(0), deletedAt: null });
|
||||
const builder: any = {
|
||||
selectFrom: jest.fn(() => builder),
|
||||
select: jest.fn(() => builder),
|
||||
where: jest.fn((...args: any[]) => {
|
||||
whereCalls.push(args);
|
||||
return builder;
|
||||
}),
|
||||
limit: jest.fn(() => builder),
|
||||
execute: jest.fn().mockResolvedValue(expiredRows),
|
||||
executeTakeFirst: reReadFirst,
|
||||
};
|
||||
return { builder, whereCalls, reReadFirst };
|
||||
}
|
||||
|
||||
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();
|
||||
// The batch SELECT is capped so a large backlog is not pulled at once.
|
||||
expect(builder.limit).toHaveBeenCalledTimes(1);
|
||||
expect(builder.limit.mock.calls[0][0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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 NOT trash a note made permanent in the race window', async () => {
|
||||
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||
// the user clicked "Make permanent" (temporary_expires_at -> null). The
|
||||
// deadline re-read must catch this and skip the delete so the keep wins.
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a note already trashed since the batch SELECT', async () => {
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: new Date(0),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT trash a note re-armed to a future deadline in the race window', async () => {
|
||||
// The batch SELECT saw the note as expired, but before its turn in the loop
|
||||
// the user disarmed it and re-armed it to a fresh, still-future deadline
|
||||
// (temporary_expires_at -> now + 1h). The deadline re-read must catch that
|
||||
// the note is no longer expired and skip the delete so the keep wins.
|
||||
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
|
||||
const { builder, reReadFirst } = makeDbStub(expired);
|
||||
reReadFirst.mockResolvedValueOnce({
|
||||
temporaryExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
deletedAt: null,
|
||||
});
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
|
||||
expect(reReadFirst).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no notes are expired', async () => {
|
||||
const { builder } = makeDbStub([]);
|
||||
const pageRepo = { removePage: jest.fn() } as any;
|
||||
const service = new TemporaryNoteCleanupService(builder, pageRepo);
|
||||
|
||||
await service.sweepExpiredTemporaryNotes();
|
||||
expect(pageRepo.removePage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
|
||||
// Cap a single sweep so a large backlog (e.g. many notes created during
|
||||
// downtime under a short lifetime) is not loaded into memory at once. The
|
||||
// remainder is drained on the next hourly run; sub-hour overshoot is fine.
|
||||
private static readonly SWEEP_BATCH_LIMIT = 500;
|
||||
|
||||
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
|
||||
.limit(TemporaryNoteCleanupService.SWEEP_BATCH_LIMIT)
|
||||
.execute();
|
||||
|
||||
let trashed = 0;
|
||||
for (const page of expired) {
|
||||
try {
|
||||
// Re-check the deadline at deletion time. The SELECT above is not
|
||||
// transactional, so a user may click "Make permanent"
|
||||
// (toggleTemporary sets temporary_expires_at = null) in the window
|
||||
// between the SELECT and this per-row removePage. removePage deletes
|
||||
// by id with only a `deletedAt IS NULL` filter and never re-reads the
|
||||
// deadline, so without this guard a concurrently-kept note would
|
||||
// still be trashed. Re-read the row and skip it unless it is still
|
||||
// armed AND still expired, so a concurrent make-permanent wins.
|
||||
const current = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['temporaryExpiresAt', 'deletedAt'])
|
||||
.where('id', '=', page.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (
|
||||
!current ||
|
||||
current.deletedAt !== null ||
|
||||
current.temporaryExpiresAt === null ||
|
||||
new Date(current.temporaryExpiresAt) >= now
|
||||
) {
|
||||
// Made permanent, already trashed, or no longer expired since the
|
||||
// SELECT — leave it alone.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reuse the exact soft-delete path: recursive over children, removes
|
||||
// shares in a transaction, and emits PAGE_SOFT_DELETED (tree
|
||||
// invalidation + watcher notifications). Attribute the automatic
|
||||
// deletion to the note's creator (no schema change). Both the SELECT
|
||||
// above and removePage filter `deletedAt IS NULL`, so a double sweep
|
||||
// is idempotent.
|
||||
await this.pageRepo.removePage(
|
||||
page.id,
|
||||
// creatorId is set on every created page; a temporary note always
|
||||
// has one. Cast to satisfy the non-null deletedById parameter.
|
||||
page.creatorId as string,
|
||||
page.workspaceId,
|
||||
);
|
||||
trashed++;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to trash expired temporary note ${page.id}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (trashed > 0) {
|
||||
this.logger.debug(
|
||||
`Temporary-note cleanup completed: ${trashed} notes trashed`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Temporary-note cleanup job failed',
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class ToggleTemporaryDto {
|
||||
@IsUUID()
|
||||
pageId!: string;
|
||||
|
||||
/**
|
||||
* When omitted, the temporary state is toggled relative to its current value.
|
||||
* true -> arm the timer (now + workspace temporaryNoteHours);
|
||||
* false -> clear it (make permanent — "structure and survive").
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean;
|
||||
}
|
||||
@@ -16,8 +16,12 @@ import { TemplateLookupDto } from './dto/template-lookup.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
import { ToggleTemplateDto } from './dto/toggle-template.dto';
|
||||
import { ToggleTemporaryDto } from './dto/toggle-temporary.dto';
|
||||
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
|
||||
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -26,6 +30,7 @@ export class PageTemplateController {
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,4 +87,54 @@ export class PageTemplateController {
|
||||
|
||||
return { pageId: page.id, isTemplate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm or disarm the "death timer" on a page (`pages.temporary_expires_at`).
|
||||
* Mirror of toggle-template: requires Edit on the page/space (CASL enforced in
|
||||
* `validateCanEdit`). Arming freezes the deadline at now + the workspace's
|
||||
* temporaryNoteHours; disarming ("Make permanent") clears it. Same workspace
|
||||
* defense-in-depth as toggle-template (NotFound, never Forbidden, on mismatch).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('toggle-temporary')
|
||||
async toggleTemporary(
|
||||
@Body() dto: ToggleTemporaryDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
if (page.workspaceId !== user.workspaceId) {
|
||||
// Defense-in-depth: never act on a page outside the caller's workspace.
|
||||
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
const makeTemporary =
|
||||
typeof dto.temporary === 'boolean'
|
||||
? dto.temporary
|
||||
: page.temporaryExpiresAt == null;
|
||||
|
||||
let temporaryExpiresAt: Date | null = null;
|
||||
if (makeTemporary) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['temporaryNoteHours'])
|
||||
.where('id', '=', user.workspaceId)
|
||||
.executeTakeFirst();
|
||||
const hours =
|
||||
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS;
|
||||
temporaryExpiresAt = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage({ temporaryExpiresAt }, page.id);
|
||||
|
||||
return { pageId: page.id, temporaryExpiresAt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||
|
||||
describe('PageTemplateController.toggleTemplate', () => {
|
||||
let controller: PageTemplateController;
|
||||
@@ -40,6 +41,8 @@ describe('PageTemplateController.toggleTemplate', () => {
|
||||
{ provide: TransclusionService, useValue: transclusionService },
|
||||
{ provide: PageRepo, useValue: pageRepo },
|
||||
{ provide: PageAccessService, useValue: pageAccessService },
|
||||
// toggleTemporary reads the workspace lifetime; toggleTemplate ignores it.
|
||||
{ provide: KYSELY_MODULE_CONNECTION_TOKEN(), useValue: {} },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { KYSELY_MODULE_CONNECTION_TOKEN } from 'nestjs-kysely';
|
||||
import { PageTemplateController } from '../page-template.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { ToggleTemporaryDto } from '../dto/toggle-temporary.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../../constants/temporary-note.constants';
|
||||
|
||||
/**
|
||||
* Minimal chainable Kysely stub: every builder method returns `this`, and the
|
||||
* terminal `executeTakeFirst` resolves the configured workspace row.
|
||||
*/
|
||||
function makeDbStub(workspaceRow: { temporaryNoteHours: number | null } | undefined) {
|
||||
const builder: any = {
|
||||
selectFrom: () => builder,
|
||||
select: () => builder,
|
||||
where: () => builder,
|
||||
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
describe('PageTemplateController.toggleTemporary', () => {
|
||||
let controller: PageTemplateController;
|
||||
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
|
||||
let pageAccessService: { validateCanEdit: jest.Mock };
|
||||
|
||||
const user = { id: 'u1', workspaceId: 'w1' } as any;
|
||||
|
||||
async function buildController(
|
||||
page: any,
|
||||
workspaceRow: { temporaryNoteHours: number | null } | undefined = {
|
||||
temporaryNoteHours: null,
|
||||
},
|
||||
) {
|
||||
pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(page),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageAccessService = {
|
||||
validateCanEdit: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [PageTemplateController],
|
||||
providers: [
|
||||
{ provide: TransclusionService, useValue: { lookupTemplate: jest.fn() } },
|
||||
{ provide: PageRepo, useValue: pageRepo },
|
||||
{ provide: PageAccessService, useValue: pageAccessService },
|
||||
{
|
||||
provide: KYSELY_MODULE_CONNECTION_TOKEN(),
|
||||
useValue: makeDbStub(workspaceRow),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(UserThrottlerGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get(PageTemplateController);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('throws NotFound and does not touch the page when missing', async () => {
|
||||
await buildController(null);
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFound (not Forbidden) for a cross-workspace page', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'OTHER',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces CASL edit: when validateCanEdit throws, the timer is NOT changed', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(new ForbiddenException());
|
||||
await expect(
|
||||
controller.toggleTemporary({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('arms the timer (toggle) using the default hours when the page is permanent', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
});
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
|
||||
const expected = new Date(
|
||||
Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: expected },
|
||||
'p1',
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: expected });
|
||||
});
|
||||
|
||||
it('uses the workspace temporaryNoteHours override when set', async () => {
|
||||
await buildController(
|
||||
{
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null,
|
||||
},
|
||||
{ temporaryNoteHours: 3 },
|
||||
);
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
const expected = new Date(Date.now() + 3 * 60 * 60 * 1000);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: expected },
|
||||
'p1',
|
||||
);
|
||||
expect(out.temporaryExpiresAt).toEqual(expected);
|
||||
});
|
||||
|
||||
it('clears the timer (make permanent) when toggling an armed note', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: new Date('2026-06-27T00:00:00.000Z'),
|
||||
});
|
||||
const out = await controller.toggleTemporary({ pageId: 'p1' } as any, user);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: null },
|
||||
'p1',
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', temporaryExpiresAt: null });
|
||||
});
|
||||
|
||||
it('respects an explicit temporary:false instead of toggling', async () => {
|
||||
await buildController({
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
temporaryExpiresAt: null, // already permanent, but explicit false
|
||||
});
|
||||
const out = await controller.toggleTemporary(
|
||||
{ pageId: 'p1', temporary: false } as any,
|
||||
user,
|
||||
);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ temporaryExpiresAt: null },
|
||||
'p1',
|
||||
);
|
||||
expect(out.temporaryExpiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToggleTemporaryDto validation (class-validator)', () => {
|
||||
const uuid = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
it('accepts a valid UUID with no flag (toggle)', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, { pageId: uuid });
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts an explicit boolean temporary', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||
pageId: uuid,
|
||||
temporary: true,
|
||||
});
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a non-UUID pageId', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, { pageId: 'nope' });
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isUuid');
|
||||
});
|
||||
|
||||
it('rejects a non-boolean temporary', async () => {
|
||||
const dto = plainToInstance(ToggleTemporaryDto, {
|
||||
pageId: uuid,
|
||||
temporary: 'yes',
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isBoolean');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { NoResultError } from 'kysely';
|
||||
import { ShareAliasService } from './share-alias.service';
|
||||
|
||||
/**
|
||||
@@ -7,13 +8,18 @@ import { ShareAliasService } from './share-alias.service';
|
||||
* request-time readable-target resolution (which re-runs the share boundary).
|
||||
*/
|
||||
describe('ShareAliasService', () => {
|
||||
// Sentinel handed to repo calls so tests can assert they ran inside the tx.
|
||||
const trx = { __trx: true };
|
||||
|
||||
function makeService() {
|
||||
const shareAliasRepo = {
|
||||
findByAliasAndWorkspace: jest.fn(),
|
||||
findByPageId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
updateAlias: jest.fn(),
|
||||
updatePageId: jest.fn(),
|
||||
deleteOthersForPage: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
@@ -21,12 +27,19 @@ describe('ShareAliasService', () => {
|
||||
resolveReadableSharePage: jest.fn(),
|
||||
isSharingAllowed: jest.fn(),
|
||||
};
|
||||
// Fake kysely db: only .transaction().execute(cb) is used by setAlias.
|
||||
const db = {
|
||||
transaction: jest.fn(() => ({
|
||||
execute: jest.fn(async (cb: any) => cb(trx)),
|
||||
})),
|
||||
};
|
||||
const service = new ShareAliasService(
|
||||
shareAliasRepo as any,
|
||||
pageRepo as any,
|
||||
shareService as any,
|
||||
db as any,
|
||||
);
|
||||
return { service, shareAliasRepo, pageRepo, shareService };
|
||||
return { service, shareAliasRepo, pageRepo, shareService, db };
|
||||
}
|
||||
|
||||
describe('setAlias', () => {
|
||||
@@ -43,9 +56,10 @@ describe('ShareAliasService', () => {
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes then inserts a brand-new alias', async () => {
|
||||
it('normalizes then inserts a brand-new alias (page has none yet)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockResolvedValue({ id: 'a-1', alias: 'my-page' });
|
||||
|
||||
const res = await service.setAlias({
|
||||
@@ -58,17 +72,70 @@ describe('ShareAliasService', () => {
|
||||
expect(shareAliasRepo.findByAliasAndWorkspace).toHaveBeenCalledWith(
|
||||
'my-page',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
},
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
|
||||
// self-heal still runs, keeping just the inserted row
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.insert).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
alias: 'my-page',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
});
|
||||
expect(res).toMatchObject({ id: 'a-1' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page', async () => {
|
||||
it('renames the existing row in place when editing to a free name (te -> ted)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// The new slug is free...
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
// ...but the page already owns an alias named `te`.
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'te',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareAliasRepo.updateAlias.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'ted',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
|
||||
const res = await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'ted',
|
||||
});
|
||||
|
||||
// RENAME, not INSERT a second row.
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updateAlias).toHaveBeenCalledWith(
|
||||
'a-1',
|
||||
'ted',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
// ...and any other row for the page is reaped, so `te` cannot survive.
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(res).toMatchObject({ id: 'a-1', alias: 'ted' });
|
||||
});
|
||||
|
||||
it('is a no-op when the alias already points at the same page (and self-heals)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
const existing = { id: 'a-1', alias: 'foo', pageId: 'p-1' };
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(existing);
|
||||
@@ -82,7 +149,45 @@ describe('ShareAliasService', () => {
|
||||
|
||||
expect(res).toBe(existing);
|
||||
expect(shareAliasRepo.insert).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updateAlias).not.toHaveBeenCalled();
|
||||
expect(shareAliasRepo.updatePageId).not.toHaveBeenCalled();
|
||||
// self-heal reaps any legacy duplicate rows for the page
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
it('self-heals a page with pre-existing duplicate rows down to one', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// Name free; the page already has a (legacy) alias row we rename.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-keep',
|
||||
alias: 'old',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
shareAliasRepo.updateAlias.mockResolvedValue({
|
||||
id: 'a-keep',
|
||||
alias: 'new',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'new',
|
||||
});
|
||||
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-keep',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 409 with current target when name is taken and not confirmed', async () => {
|
||||
@@ -134,15 +239,190 @@ describe('ShareAliasService', () => {
|
||||
'a-1',
|
||||
'p-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
// ORDER MATTERS: the target page's existing alias row(s) are reaped BEFORE
|
||||
// the retarget, so the non-deferrable (workspace_id, page_id) index never
|
||||
// sees two rows for the page mid-statement. There is no trailing self-heal.
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledWith(
|
||||
'p-1',
|
||||
'a-1',
|
||||
'ws-1',
|
||||
trx,
|
||||
);
|
||||
expect(shareAliasRepo.deleteOthersForPage).toHaveBeenCalledTimes(1);
|
||||
const deleteOrder =
|
||||
shareAliasRepo.deleteOthersForPage.mock.invocationCallOrder[0];
|
||||
const updateOrder =
|
||||
shareAliasRepo.updatePageId.mock.invocationCallOrder[0];
|
||||
expect(deleteOrder).toBeLessThan(updateOrder);
|
||||
expect(res).toMatchObject({ pageId: 'p-1' });
|
||||
});
|
||||
|
||||
it('maps a unique-violation race to 409', async () => {
|
||||
it('maps a unique-violation race (no constraint info) to 409 "Alias already taken"', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockRejectedValue({ code: '23505' });
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
message: 'Alias already taken',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the (workspace_id, alias) index violation to "Alias already taken"', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
// postgres@3.x driver exposes the index name as `constraint_name`.
|
||||
shareAliasRepo.insert.mockRejectedValue({
|
||||
code: '23505',
|
||||
constraint_name: 'share_aliases_workspace_id_alias_unique',
|
||||
});
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
message: 'Alias already taken',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps the (workspace_id, page_id) index violation to a DISTINCT page-race outcome', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockRejectedValue({
|
||||
code: '23505',
|
||||
constraint_name: 'share_aliases_workspace_id_page_id_unique',
|
||||
});
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
// NOT the misleading "Alias already taken" — a separate, page-scoped code.
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
expect((err as ConflictException).getResponse()).not.toMatchObject({
|
||||
message: 'Alias already taken',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the index name from `.constraint` when `.constraint_name` is absent', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
// Fallback path for non-postgres@3.x drivers.
|
||||
shareAliasRepo.insert.mockRejectedValue({
|
||||
code: '23505',
|
||||
constraint: 'share_aliases_workspace_id_page_id_unique',
|
||||
});
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a concurrent-delete race in the SWAP branch to a retryable 409 (not a 200-without-alias)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// Name points at another page; reassign confirmed -> swap branch.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'foo',
|
||||
pageId: 'p-other',
|
||||
});
|
||||
// A concurrent removeAlias deleted the row between read and UPDATE, so the
|
||||
// repo's executeTakeFirstOrThrow finds 0 rows and throws NoResultError.
|
||||
shareAliasRepo.updatePageId.mockRejectedValue(
|
||||
new NoResultError({} as any),
|
||||
);
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
confirmReassign: true,
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
// Crucially NOT a resolved 200 carrying `undefined` as the alias.
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a concurrent-delete race in the RENAME branch to a retryable 409 (not a generic 400)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
// New slug is free, but the page already owns an alias we rename in place.
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.findByPageId.mockResolvedValue({
|
||||
id: 'a-1',
|
||||
alias: 'te',
|
||||
pageId: 'p-1',
|
||||
});
|
||||
// The row vanished before the UPDATE; repo throws NoResultError rather
|
||||
// than returning undefined (which would dereference undefined.id -> 400).
|
||||
shareAliasRepo.updateAlias.mockRejectedValue(new NoResultError({} as any));
|
||||
|
||||
try {
|
||||
await service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
pageId: 'p-1',
|
||||
creatorId: 'u-1',
|
||||
alias: 'ted',
|
||||
});
|
||||
fail('expected ConflictException');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
expect(err).not.toBeInstanceOf(BadRequestException);
|
||||
expect((err as ConflictException).getResponse()).toMatchObject({
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a non-unique-violation db error to BadRequest (Failed to set alias)', async () => {
|
||||
const { service, shareAliasRepo } = makeService();
|
||||
shareAliasRepo.findByAliasAndWorkspace.mockResolvedValue(undefined);
|
||||
shareAliasRepo.insert.mockRejectedValue({ code: '08006' }); // connection error
|
||||
|
||||
await expect(
|
||||
service.setAlias({
|
||||
workspaceId: 'ws-1',
|
||||
@@ -150,7 +430,7 @@ describe('ShareAliasService', () => {
|
||||
creatorId: 'u-1',
|
||||
alias: 'foo',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,24 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { ShareService } from './share.service';
|
||||
import { Page, ShareAlias } from '@docmost/db/types/entity.types';
|
||||
import { isValidShareAlias, normalizeShareAlias } from './share-alias.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
executeTx,
|
||||
isUniqueViolation,
|
||||
violatedConstraint,
|
||||
} from '@docmost/db/utils';
|
||||
import { NoResultError } from 'kysely';
|
||||
|
||||
/** Postgres unique_violation; the (workspace_id, alias) constraint races here. */
|
||||
const PG_UNIQUE_VIOLATION = '23505';
|
||||
/**
|
||||
* Unique index name from the share_aliases migrations whose violation we map to
|
||||
* a DISTINCT, non-misleading outcome:
|
||||
* - PAGE_ID: partial `(workspace_id, page_id) WHERE page_id IS NOT NULL`
|
||||
* -> a concurrent writer already gave THIS page an alias.
|
||||
* The `(workspace_id, alias)` index (the vanity NAME being taken) needs no
|
||||
* constant: it is the default "Alias already taken" mapping.
|
||||
*/
|
||||
const UNIQUE_PAGE_ID_INDEX = 'share_aliases_workspace_id_page_id_unique';
|
||||
|
||||
export interface ResolvedAliasTarget {
|
||||
share: NonNullable<
|
||||
@@ -28,16 +43,30 @@ export class ShareAliasService {
|
||||
private readonly shareAliasRepo: ShareAliasRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly shareService: ShareService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create or retarget a vanity alias. The alias is workspace-scoped:
|
||||
* - no row for this name -> INSERT a new pointer
|
||||
* - row already points at pageId -> no-op (idempotent)
|
||||
* - row points elsewhere -> the "swap". Without confirmReassign we
|
||||
* throw 409 carrying the current target so the client can confirm; with
|
||||
* it we UPDATE the single row's page_id (every /l/<alias> link follows the
|
||||
* 302 to the new page instantly — no stale 301 cache).
|
||||
* Create, RENAME or retarget a page's vanity alias. INVARIANT: a page has
|
||||
* EXACTLY ONE custom address. The alias name is workspace-scoped:
|
||||
* - name free, page has no alias yet -> INSERT a new pointer
|
||||
* - name free, page already has one -> RENAME that row in place (the slug
|
||||
* edit, e.g. `te` -> `ted`); we never spawn a second row, so no orphan
|
||||
* `/l/<old>` link survives
|
||||
* - name already points at pageId -> no-op (idempotent)
|
||||
* - name points at ANOTHER page -> the "swap". Without confirmReassign
|
||||
* we throw 409 carrying the current target so the client can confirm;
|
||||
* with it we UPDATE the single row's page_id (every /l/<alias> link
|
||||
* follows the 302 to the new page instantly — no stale cache).
|
||||
*
|
||||
* To keep the invariant self-healing we DELETE every other alias row still
|
||||
* pointing at this page (a legacy duplicate, or the target page's own former
|
||||
* alias during a swap). The whole thing runs in one transaction. Because the
|
||||
* `(workspace_id, page_id)` unique index is NON-deferrable (checked at the end
|
||||
* of each statement), the swap branch DELETEs the target page's existing row
|
||||
* BEFORE retargeting, so the page is never transiently carried by two rows;
|
||||
* the other branches self-heal AFTER their write. Either way the page never
|
||||
* ends a statement with duplicate rows.
|
||||
*
|
||||
* Caller is responsible for authorizing the page (edit rights + public
|
||||
* readability); this method owns only the alias-name semantics.
|
||||
@@ -57,48 +86,128 @@ export class ShareAliasService {
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
return await this.shareAliasRepo.insert({
|
||||
workspaceId,
|
||||
try {
|
||||
return await executeTx(this.db, async (trx) => {
|
||||
const byName = await this.shareAliasRepo.findByAliasAndWorkspace(
|
||||
alias,
|
||||
pageId,
|
||||
creatorId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Lost a uniqueness race: another request claimed the name first.
|
||||
if (err?.code === PG_UNIQUE_VIOLATION) {
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
// The name is occupied by a DIFFERENT (or dangling) target page.
|
||||
if (byName && byName.pageId !== pageId) {
|
||||
if (!confirmReassign) {
|
||||
const currentPage = byName.pageId
|
||||
? await this.pageRepo.findById(byName.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: byName.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
});
|
||||
}
|
||||
// Confirmed swap. ORDER MATTERS: the partial unique index on
|
||||
// `(workspace_id, page_id)` is NON-deferrable, so it is checked at the
|
||||
// end of EVERY statement. If we retargeted `byName` onto `pageId`
|
||||
// first while `pageId` still had its OWN alias row, there would
|
||||
// momentarily be two rows with this page_id -> immediate 23505 and a
|
||||
// rolled-back tx (a misleading "Alias already taken"). So we FIRST drop
|
||||
// the target page's existing alias row(s), THEN retarget. `byName.id`
|
||||
// still points at its old page here, so excluding it via `keepId` is
|
||||
// harmless; after the retarget it is the page's only row, so no
|
||||
// trailing self-heal is needed.
|
||||
await this.shareAliasRepo.deleteOthersForPage(
|
||||
pageId,
|
||||
byName.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
return await this.shareAliasRepo.updatePageId(
|
||||
byName.id,
|
||||
pageId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
}
|
||||
|
||||
// Already points at this page -> nothing to do.
|
||||
if (existing.pageId === pageId) {
|
||||
return existing;
|
||||
}
|
||||
// The name is FREE, or already points at THIS page. Ensure the page has
|
||||
// a single row carrying this name: rename its current one, or insert.
|
||||
const current =
|
||||
byName ??
|
||||
(await this.shareAliasRepo.findByPageId(pageId, workspaceId, trx));
|
||||
|
||||
// Name occupied by a different (or dangling) target: require confirmation.
|
||||
if (!confirmReassign) {
|
||||
const currentPage = existing.pageId
|
||||
? await this.pageRepo.findById(existing.pageId)
|
||||
: null;
|
||||
throw new ConflictException({
|
||||
message: 'Alias already in use',
|
||||
code: 'ALIAS_REASSIGN_REQUIRED',
|
||||
currentPageId: existing.pageId,
|
||||
currentPageTitle: currentPage?.title ?? null,
|
||||
let row: ShareAlias;
|
||||
if (current) {
|
||||
row =
|
||||
current.alias === alias
|
||||
? current // same-name no-op
|
||||
: await this.shareAliasRepo.updateAlias(
|
||||
current.id,
|
||||
alias,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else {
|
||||
row = await this.shareAliasRepo.insert(
|
||||
{ workspaceId, alias, pageId, creatorId },
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
// Self-heal: a page keeps EXACTLY ONE custom address.
|
||||
await this.shareAliasRepo.deleteOthersForPage(
|
||||
pageId,
|
||||
row.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
return row;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err instanceof ConflictException ||
|
||||
err instanceof BadRequestException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// The row we read was deleted (concurrent `removeAlias`) before our UPDATE
|
||||
// matched it, so `executeTakeFirstOrThrow` found no row. Surface a
|
||||
// retryable conflict instead of a 200-without-alias (swap branch) or a
|
||||
// generic 400 from dereferencing `undefined.id` (rename branch).
|
||||
if (err instanceof NoResultError) {
|
||||
this.logger.warn(
|
||||
'share alias update matched no row (concurrent-delete race)',
|
||||
);
|
||||
throw new ConflictException({
|
||||
message: 'The address changed concurrently, please retry',
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
// A unique index fired. Which one decides the message — always log the
|
||||
// constraint so the race is diagnosable.
|
||||
if (isUniqueViolation(err)) {
|
||||
const constraint = violatedConstraint(err);
|
||||
this.logger.warn(
|
||||
`share alias unique violation on ${constraint ?? '<unknown>'}`,
|
||||
);
|
||||
// `(workspace_id, page_id)`: a concurrent request already gave this page
|
||||
// an alias. The page still has exactly one custom address (the racing
|
||||
// writer's), so this is not a user-facing name clash — surface a
|
||||
// distinct, non-misleading message instead of "Alias already taken".
|
||||
if (constraint === UNIQUE_PAGE_ID_INDEX) {
|
||||
throw new ConflictException({
|
||||
message: 'This page is being given an address by another request',
|
||||
code: 'ALIAS_PAGE_RACE',
|
||||
});
|
||||
}
|
||||
// `(workspace_id, alias)` or any other/unknown unique index: treat as
|
||||
// the vanity name being claimed first.
|
||||
throw new ConflictException({ message: 'Alias already taken' });
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to set alias');
|
||||
}
|
||||
|
||||
return this.shareAliasRepo.updatePageId(existing.id, pageId, workspaceId);
|
||||
}
|
||||
|
||||
/** Free a vanity name (no history kept). */
|
||||
|
||||
@@ -31,10 +31,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
aiSearch: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
generativeAi: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
@@ -84,6 +80,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;
|
||||
|
||||
@@ -145,7 +145,7 @@ export class WorkspaceService {
|
||||
status = WorkspaceStatus.Active;
|
||||
plan = 'standard';
|
||||
billingEmail = user.email;
|
||||
settings = { ai: { generative: true, chat: true } };
|
||||
settings = { ai: { chat: true } };
|
||||
}
|
||||
|
||||
// create workspace
|
||||
@@ -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) {
|
||||
@@ -424,20 +439,6 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
const prev = settingsBefore?.ai?.generative ?? false;
|
||||
if (prev !== updateWorkspaceDto.generativeAi) {
|
||||
before.generativeAi = prev;
|
||||
after.generativeAi = updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const prev = settingsBefore?.sharing?.disabled ?? false;
|
||||
if (prev !== updateWorkspaceDto.disablePublicSharing) {
|
||||
@@ -572,7 +573,6 @@ export class WorkspaceService {
|
||||
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
delete updateWorkspaceDto.mcpEnabled;
|
||||
delete updateWorkspaceDto.allowMemberTemplates;
|
||||
|
||||
@@ -21,6 +21,41 @@ export interface TreeNodeSnapshot {
|
||||
position: string;
|
||||
spaceId: string;
|
||||
parentPageId: string | null;
|
||||
// Death-timer deadline carried so the `addTreeNode` broadcast shows the
|
||||
// temporary-note clock marker immediately on every client (incl. the author,
|
||||
// whose optimistic insert can lose the race to this broadcast). null/absent =>
|
||||
// permanent.
|
||||
temporaryExpiresAt?: Date | string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single canonical builder for a `TreeNodeSnapshot` from a page-like row. Both
|
||||
* the `PAGE_CREATED` event enrichment (`page.repo.insertPage`) and the
|
||||
* `addTreeNode` broadcast (`WsTreeService.broadcastPageCreated`) build this same
|
||||
* snapshot; routing both through here keeps the optional `temporaryExpiresAt`
|
||||
* (and the `?? null` normalisation that pins a permanent note to an explicit
|
||||
* null) from silently drifting between the two literals.
|
||||
*/
|
||||
export function toTreeNodeSnapshot(page: {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
position: string;
|
||||
spaceId: string;
|
||||
parentPageId: string | null;
|
||||
temporaryExpiresAt?: Date | string | null;
|
||||
}): TreeNodeSnapshot {
|
||||
return {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
position: page.position,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId,
|
||||
temporaryExpiresAt: page.temporaryExpiresAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export class PageEvent {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user