Автор и последний редактор: профили из довідника CRM, аватары как в CRM

This commit is contained in:
crm-wiki agent
2026-10-13 15:12:55 +00:00
parent 9f2e91bfc1
commit 523bb7cc02
9 changed files with 368 additions and 12 deletions

View File

@@ -46,9 +46,19 @@
```ts
type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number,
createdAt, updatedAt, updatedBy /* uid */, updatedByName };
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */ };
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */,
author: Person | null /* кто создал (createdAt) */,
lastEditor: Person | null /* кто последний правил (updatedAt) */ };
type Person = { id /* uid Supabase */, name: string|null, email: string|null,
avatarUrl: string|null /* публичный https-URL или data:image */, color: string|null /* кружок с инициалами */ };
```
### Автор
У каждой страницы и папки хранятся автор (`created_by` = uid из проверенного токена, `createdAt`) и последний редактор (`updated_by`, `updatedAt`); перенос и переименование тоже считаются правкой. Агенты (бот «Лана» и т. п.) — такие же авторы, как люди. Поля `author` и `lastEditor` есть в ответах `GET /api/pages/{id}`, `GET /api/nodes/{id}` и в ответах на создание и сохранение; в `/api/tree` их нет. Если автор неизвестен, поле `null`; если uid известен, а профиля нет, `name`/`avatarUrl` будут `null`.
Профиль берётся там же, где его берёт CRM: довідник виконавців в `app_config` (`id = 'main'`, `data.assignees[]`: `{ id, name, email, uid, avatar, color }`), запрос с токеном пользователя (RLS), сопоставление по `uid`, затем по email. Обновляется не чаще раза в 10 минут на пользователя и хранится в таблице `users` вики. Аватар — URL из `avatar`: CRM кладёт фото в публичный бакет Storage `logos` (`avatars/<id записи>`) и сохраняет `getPublicUrl`, поэтому картинка грузится без токена. Принимаются только `https://` и `data:image/`. Нет фото — кружок с инициалами на цвете `color` (свой цвет записи или `colorOf(id)`, как в CRM). Нет записи в довіднику (например, бот) — имя из токена (`user_metadata.name` → email), инициалы.
Формат текста — Markdown (GFM: таблицы, списки задач). Ссылка на другую страницу: `[текст](/p/<id>)`, картинка: `![alt](/api/images/<id>)`. Ссылки по id не ломаются при переименовании и переносе.
Пример (агент или скрипт):

View File

@@ -0,0 +1,129 @@
using System.Net;
using Microsoft.Extensions.Configuration;
using Wiki.Api;
namespace Wiki.Api.Tests;
/// <summary>Профили авторов: довідник CRM (app_config.data.assignees) → имя, аватар, цвет.</summary>
public class ProfilesTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wiki-tests-" + Guid.NewGuid().ToString("N"));
private const string Config = """
[{"data":{"assignees":[
{"id":"a2","name":"Лана","email":"LANA@test","uid":"u-lana","avatar":"https://x.supabase.co/storage/v1/object/public/logos/avatars/a2.webp?v=1"},
{"id":"a3","name":"Олег","email":"oleg@test","uid":"u-oleg","color":"#4ADE80","avatar":"javascript:alert(1)"},
{"id":"a4","name":"Без входу","email":"none@test"}
]}}]
""";
private sealed class Fake(string body) : HttpMessageHandler
{
public int Calls;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken ct)
{
Calls++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) });
}
}
private sealed class Factory(HttpMessageHandler h) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(h, disposeHandler: false);
}
private (Profiles P, Db Db, Fake Fake) Make(string body = Config)
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Storage:DataDir"] = _dir, ["Supabase:Url"] = "https://supabase.test", ["Supabase:PublishableKey"] = "k",
}).Build();
var db = new Db(cfg); db.Init();
var fake = new Fake(body);
return (new Profiles(db, new Factory(fake), cfg), db, fake);
}
private static WikiUser U(string id, string? email, string? name = null) => new(id, email, name, 0);
private static Person Load(Db db, string uid) { using var c = db.Open(); return Profiles.Load(c, uid)!; }
[Theory]
[InlineData("a2", "#5EEAD4")]
[InlineData("mv5abc123", "#C4B5FD")]
[InlineData("Лана", "#A78BFA")]
[InlineData("x😀y", "#FFCA40")]
public void ColorOf_MatchesCrm(string key, string expected) => Assert.Equal(expected, Profiles.ColorOf(key));
[Fact]
public async Task MatchByUid_TakesCrmNameAvatarAndColor()
{
var (p, db, _) = Make();
await p.EnsureAsync(U("u-lana", "lana@test", "токен-ім'я"), "t", default);
var me = Load(db, "u-lana");
Assert.Equal("Лана", me.Name);
Assert.StartsWith("https://x.supabase.co/storage/v1/object/public/logos/avatars/", me.AvatarUrl);
Assert.Equal("#5EEAD4", me.Color); // своего цвета нет — colorOf(id записи), как в CRM
}
[Fact]
public async Task MatchByEmail_CaseInsensitive()
{
var (p, db, _) = Make();
await p.EnsureAsync(U("other-uid", "lana@TEST"), "t", default);
Assert.Equal("Лана", Load(db, "other-uid").Name);
}
[Fact]
public async Task UnsafeAvatar_Dropped_OwnColorKept()
{
var (p, db, _) = Make();
await p.EnsureAsync(U("u-oleg", "oleg@test"), "t", default);
var o = Load(db, "u-oleg");
Assert.Null(o.AvatarUrl);
Assert.Equal("#4ADE80", o.Color);
}
[Fact]
public async Task Bot_NotInDirectory_NameFromToken()
{
var (p, db, _) = Make();
await p.EnsureAsync(U("u-bot", "bot@test", "Лана (бот)"), "t", default);
var b = Load(db, "u-bot");
Assert.Equal("Лана (бот)", b.Name);
Assert.Null(b.AvatarUrl);
}
[Fact]
public async Task DirectoryUnavailable_FallsBackToToken_AndKeepsAvatar()
{
var (p, db, _) = Make();
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
var (p2, _, _) = (new Profiles(db, new Factory(new Fake("not json")),
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Supabase:Url"] = "https://s" }).Build()), db, 0);
await p2.EnsureAsync(U("u-lana", "lana@test", "Інше"), "t", default);
Assert.NotNull(Load(db, "u-lana").AvatarUrl);
}
[Fact]
public async Task CachedForTenMinutes()
{
var (p, _, fake) = Make();
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
await p.EnsureAsync(U("u-lana", "lana@test"), "t", default);
Assert.Equal(1, fake.Calls);
}
[Fact]
public void NoAuthor_IsNull()
{
var (_, db, _) = Make();
using var c = db.Open();
Assert.Null(Profiles.Load(c, null));
Assert.Null(Profiles.Load(c, ""));
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
}
}

View File

@@ -73,6 +73,21 @@ public sealed class Db
created_by TEXT NOT NULL
);
-- Профили людей и агентов (автор, последний редактор): имя, почта, аватар и цвет кружка из CRM.
CREATE TABLE IF NOT EXISTS users(
uid TEXT PRIMARY KEY,
name TEXT NULL,
email TEXT NULL,
avatar_url TEXT NULL,
color TEXT NULL,
updated_at TEXT NOT NULL
);
-- Для уже существующих записей — хотя бы имя, сохранённое при правке.
INSERT OR IGNORE INTO users(uid, name, updated_at)
SELECT updated_by, updated_by_name, max(updated_at) FROM nodes
WHERE updated_by IS NOT NULL AND updated_by <> '' AND updated_by_name IS NOT NULL
GROUP BY updated_by;
-- Полнотекстовый поиск по названию и тексту (external content на nodes).
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
title, content,

View File

@@ -0,0 +1,144 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace Wiki.Api;
/// <summary>Человек или агент в ответах API: автор и последний редактор.</summary>
public sealed record Person(string Id, string? Name, string? Email, string? AvatarUrl, string? Color);
/// <summary>
/// Профили берём там же, где CRM: довідник виконавців — JSON в app_config (id = 'main'), поле data.assignees[]
/// ({ id, name, email, uid, avatar, color }). avatar — публичный URL бакета Storage `logos` (avatars/&lt;id&gt;).
/// Запрос идёт с токеном пользователя (RLS). Обновляем не чаще раза в 10 минут на пользователя.
/// </summary>
public sealed class Profiles(Db db, IHttpClientFactory http, IConfiguration cfg)
{
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
private readonly ConcurrentDictionary<string, DateTimeOffset> _fresh = new();
private static readonly TimeSpan RefreshEvery = TimeSpan.FromMinutes(10);
private sealed record Assignee(string? Id, string? Uid, string? Name, string? Email, string? Avatar, string? Color)
{
/// <summary>Цвет кружка как в CRM: свой или подобранный по id записи (avatarHtml → colorOf).</summary>
public string? Circle => SafeColor(Color) ?? (string.IsNullOrEmpty(Id) ? null : ColorOf(Id));
}
/// <summary>Палитра и алгоритм — копия colorOf из CRM (js/util.js), чтобы цвета совпадали.</summary>
private static readonly string[] AvatarColors =
["#FFCA40", "#7DD3FC", "#A78BFA", "#4ADE80", "#FB923C", "#F472B6", "#F97066", "#5EEAD4", "#C4B5FD", "#E5E5E5"];
public static string ColorOf(string s)
{
long h = 0;
for (var i = 0; i < s.Length; i += char.IsSurrogatePair(s, i) ? 2 : 1)
h = (h * 31 + char.ConvertToUtf32(s, i)) % 9973;
return AvatarColors[h % AvatarColors.Length];
}
public async Task EnsureAsync(WikiUser u, string token, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
if (_fresh.TryGetValue(u.Id, out var until) && until > now) return;
_fresh[u.Id] = now + RefreshEvery;
var team = await FetchTeamAsync(token, ct);
var email = u.Email?.Trim().ToLowerInvariant();
var mine = team?.FirstOrDefault(a => a.Uid == u.Id)
?? team?.FirstOrDefault(a => email is not null && a.Email?.Trim().ToLowerInvariant() == email);
var name = Clean(mine?.Name) ?? Clean(u.Name) ?? Clean(u.Email?.Split('@')[0]);
using var c = db.Open();
using var tx = c.BeginTransaction();
// Свой профиль: из CRM, если нашёлся; иначе из токена (бот без записи в довіднику — тоже автор).
Upsert(c, u.Id, name, u.Email ?? mine?.Email, mine is null ? null : Avatar(mine.Avatar), mine?.Circle, keepVisual: team is null);
// Заодно обновляем остальных из довідника (кто уже входил в CRM — у них есть uid).
if (team is not null)
foreach (var a in team.Where(a => !string.IsNullOrEmpty(a.Uid) && a.Uid != u.Id))
Upsert(c, a.Uid!, Clean(a.Name), a.Email, Avatar(a.Avatar), a.Circle, keepVisual: false, onlyExisting: true);
tx.Commit();
}
private async Task<List<Assignee>?> FetchTeamAsync(string token, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/rest/v1/app_config?id=eq.main&select=data");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) return null;
var row = doc.RootElement[0];
if (!row.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) return null;
if (!data.TryGetProperty("assignees", out var list) || list.ValueKind != JsonValueKind.Array) return null;
return list.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.Object)
.Select(a => new Assignee(Str(a, "id"), Str(a, "uid"), Str(a, "name"), Str(a, "email"), Str(a, "avatar"), Str(a, "color")))
.ToList();
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
}
/// <param name="keepVisual">не трогать аватар и цвет (довідник не прочитали)</param>
/// <param name="onlyExisting">только обновить уже известных вики (не заводить всех из довідника)</param>
private static void Upsert(SqliteConnection c, string uid, string? name, string? email, string? avatar, string? color,
bool keepVisual, bool onlyExisting = false)
{
using var cmd = c.CreateCommand();
cmd.CommandText = onlyExisting
? """
UPDATE users SET name = coalesce($n, name), email = coalesce($e, email), avatar_url = $a, color = $c, updated_at = $now
WHERE uid = $u
"""
: keepVisual
? """
INSERT INTO users(uid, name, email, updated_at) VALUES ($u, $n, $e, $now)
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
updated_at = excluded.updated_at
"""
: """
INSERT INTO users(uid, name, email, avatar_url, color, updated_at) VALUES ($u, $n, $e, $a, $c, $now)
ON CONFLICT(uid) DO UPDATE SET name = coalesce(excluded.name, name), email = coalesce(excluded.email, email),
avatar_url = excluded.avatar_url, color = excluded.color, updated_at = excluded.updated_at
""";
cmd.Parameters.AddWithValue("$u", uid);
cmd.Parameters.AddWithValue("$n", (object?)name ?? DBNull.Value);
cmd.Parameters.AddWithValue("$e", (object?)email ?? DBNull.Value);
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
cmd.Parameters.AddWithValue("$c", (object?)SafeColor(color) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$now", DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"));
cmd.ExecuteNonQuery();
}
public static Person? Load(SqliteConnection c, string? uid)
{
if (string.IsNullOrEmpty(uid)) return null; // автора нет — поле пустое
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT name, email, avatar_url, color FROM users WHERE uid = $u";
cmd.Parameters.AddWithValue("$u", uid);
using var r = cmd.ExecuteReader();
if (!r.Read()) return new Person(uid, null, null, null, null);
string? S(int i) => r.IsDBNull(i) ? null : r.GetString(i);
return new Person(uid, S(0), S(1), S(2), S(3));
}
/// <summary>Только https-URL или небольшая data:image (CRM без облака) — то, что можно отдать в &lt;img src&gt;.</summary>
private static string? Avatar(string? v)
{
if (string.IsNullOrWhiteSpace(v)) return null;
v = v.Trim();
if (v.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && v.Length <= 2048) return v;
if (v.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase) && v.Length <= 400_000) return v;
return null;
}
private static string? SafeColor(string? v) =>
v is not null && System.Text.RegularExpressions.Regex.IsMatch(v, "^#[0-9A-Fa-f]{3,8}$") ? v : null;
private static string? Clean(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string? Str(JsonElement e, string p) =>
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}

View File

@@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<Store>();
builder.Services.AddSingleton<SupabaseAuth>();
builder.Services.AddSingleton<Profiles>();
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = Images.MaxBytes + 64 * 1024);
@@ -91,6 +92,9 @@ app.Use(async (ctx, next) =>
var ws = ctx.Request.Headers["X-Wiki-Workspace"].ToString().Trim();
if (ws.Length == 0) ws = SupabaseAuth.Shared;
if (!await sa.CanAccessAsync(user, token, ws, ctx.RequestAborted)) { await Deny(ctx, 403, "немає доступу до воркспейсу"); return; }
// Профиль (имя, аватар из довідника CRM) — для поля «автор»; сбой не мешает запросу.
try { await ctx.RequestServices.GetRequiredService<Profiles>().EnsureAsync(user, token, ctx.RequestAborted); }
catch (Exception e) when (e is not OperationCanceledException) { /* профиль обновится в следующий раз */ }
ctx.Items["user"] = user;
ctx.Items["token"] = token;
ctx.Items["ws"] = ws;

View File

@@ -13,7 +13,8 @@ public sealed record Crumb(string Id, string Kind, string Title);
public sealed record PageDto(
string Id, string? ParentId, string Kind, string Title, int Version,
string CreatedAt, string UpdatedAt, string UpdatedBy, string? UpdatedByName,
string Markdown, IReadOnlyList<Crumb> Path);
string Markdown, IReadOnlyList<Crumb> Path,
Person? Author, Person? LastEditor);
public sealed record SearchHit(string Id, string Kind, string Title, string Snippet, IReadOnlyList<Crumb> Path);
@@ -56,15 +57,17 @@ public sealed partial class Store(Db db)
private static PageDto? Get(SqliteConnection c, string ws, string id)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT {Cols}, content FROM nodes WHERE workspace_id=$ws AND id=$id";
cmd.CommandText = $"SELECT {Cols}, content, created_by FROM nodes WHERE workspace_id=$ws AND id=$id";
cmd.Parameters.AddWithValue("$ws", ws);
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
var n = ReadNode(r);
var content = r.GetString(9);
var createdBy = r.IsDBNull(10) ? null : r.GetString(10);
r.Close();
return new PageDto(n.Id, n.ParentId, n.Kind, n.Title, n.Version, n.CreatedAt, n.UpdatedAt, n.UpdatedBy,
n.UpdatedByName, content, Path(c, ws, n.ParentId));
n.UpdatedByName, content, Path(c, ws, n.ParentId), Profiles.Load(c, createdBy), Profiles.Load(c, n.UpdatedBy));
}
/// <summary>Цепочка папок от корня до parentId включительно (хлебные крошки).</summary>

39
frontend/src/Avatar.tsx Normal file
View File

@@ -0,0 +1,39 @@
import type { Person } from "./api";
// Кружок профиля как в CRM (ui.js → avatarHtml): фото или инициалы на цвете.
// Палитра и colorOf — копия js/util.js CRM; цвет обычно уже приходит с сервера.
const AVATAR_COLORS = ["#FFCA40", "#7DD3FC", "#A78BFA", "#4ADE80", "#FB923C", "#F472B6", "#F97066", "#5EEAD4", "#C4B5FD", "#E5E5E5"];
const colorOf = (s: string) => {
let h = 0;
for (const ch of s) h = (h * 31 + ch.codePointAt(0)!) % 9973;
return AVATAR_COLORS[h % AVATAR_COLORS.length];
};
const monogram = (name: string) => {
const w = name.trim().split(/\s+/).filter(Boolean);
return w.length ? w.slice(0, 2).map((s) => [...s][0]).join("").toUpperCase() : "—";
};
export const personName = (p: Person | null) => p?.name || p?.email || "Невідомий користувач";
export function Avatar({ p, size = 18 }: { p: Person; size?: number }) {
const name = personName(p);
// Размер и цвет зависят от данных — inline, как в CRM.
if (p.avatarUrl)
return <img className="av" src={p.avatarUrl} alt={name} referrerPolicy="no-referrer" style={{ width: size, height: size }} />;
return (
<i className="av" aria-hidden="true"
style={{ background: p.color || colorOf(name), width: size, height: size, fontSize: Math.round(size * 0.44) }}>
{monogram(name)}
</i>
);
}
/** «аватар + имя»; автор — в золотом кольце (.by), как автор задачи в CRM. */
export function Who({ p, author = false }: { p: Person; author?: boolean }) {
return (
<span className="who">
{author ? <span className="by"><Avatar p={p} /></span> : <Avatar p={p} />}
<span>{personName(p)}</span>
</span>
);
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Api, ApiError, type Node, type Page } from "./api";
import { Who } from "./Avatar";
import { WikiEditor } from "./Editor";
import { Icon } from "./Icon";
import { setLeaveGuard } from "./router";
@@ -25,7 +26,7 @@ export function PageView(props: {
const [title, setTitle] = useState(props.page.title);
const [state, setState] = useState<SaveState>("saved");
const [err, setErr] = useState("");
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName });
const [meta, setMeta] = useState({ updatedAt: props.page.updatedAt, by: props.page.updatedByName, editor: props.page.lastEditor });
const [conflict, setConflict] = useState<Page | null>(null);
const [editorKey, setEditorKey] = useState(0);
const [markdown, setMarkdown] = useState(props.page.markdown);
@@ -52,7 +53,7 @@ export function PageView(props: {
version.current = p.version;
const titleChanged = p.title !== savedTitle.current;
savedTitle.current = p.title;
setMeta({ updatedAt: p.updatedAt, by: p.updatedByName });
setMeta({ updatedAt: p.updatedAt, by: p.updatedByName, editor: p.lastEditor });
setState(dirty.current ? "dirty" : "saved");
setErr("");
props.onSaved(p, titleChanged);
@@ -105,7 +106,7 @@ export function PageView(props: {
setTitle(conflict.title);
setMarkdown(conflict.markdown);
getMd.current = () => conflict.markdown;
setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName });
setMeta({ updatedAt: conflict.updatedAt, by: conflict.updatedByName, editor: conflict.lastEditor });
dirty.current = false;
setConflict(null);
setState("saved");
@@ -152,10 +153,19 @@ export function PageView(props: {
<button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button>
)}
</div>
{editing ? (
<div className="row wrap small mute mt-6">
{editing && status}
{status}
<span>змінено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""}</span>
</div>
) : (
<div className="row wrap small mute mt-6 gap-14">
<span className="row gap-6">Автор: {props.page.author ? <Who p={props.page.author} author /> : "—"}
<span>· {fmt(props.page.createdAt)}</span></span>
<span className="row gap-6">Змінено: {meta.editor ? <Who p={meta.editor} /> : (meta.by ?? "—")}
<span>· {fmt(meta.updatedAt)}</span></span>
</div>
)}
{leaveMsg && <div className="err">{leaveMsg}</div>}
{state === "error" && err && <div className="err">{err}</div>}
{conflict && (

View File

@@ -5,7 +5,9 @@ export type Node = {
createdAt: string; updatedAt: string; updatedBy: string; updatedByName: string | null;
};
export type Crumb = { id: string; kind: "folder" | "page"; title: string };
export type Page = Node & { markdown: string; path: Crumb[] };
/** Автор или последний редактор: профиль из довідника CRM (у агентов — из токена). */
export type Person = { id: string; name: string | null; email: string | null; avatarUrl: string | null; color: string | null };
export type Page = Node & { markdown: string; path: Crumb[]; author: Person | null; lastEditor: Person | null };
export type SearchHit = { id: string; kind: "folder" | "page"; title: string; snippet: string; path: Crumb[] };
export class ApiError extends Error {