Files
edgeever/apps/api/src/notebook-service.ts
T

390 lines
12 KiB
TypeScript

import type { Notebook, NotebookCreateInput } from "@edgeever/shared";
import type { AuditActor } from "./api-context";
import { AppError } from "./app-error";
import { audit, auditStatement } from "./audit";
import { createId, isoNow, slugify } from "./entity-utils";
import type { DatabaseAdapter } from "./storage-contract";
import { isInboxNotebook, workspaceInboxId } from "./workspace-provisioning";
export { isInboxNotebook, workspaceInboxId } from "./workspace-provisioning";
/** SQL identity for the workspace inbox: stable id first, slug as a fallback. */
export const inboxNotebookIdentitySql = (alias: string) =>
`(${alias}.id = ${alias}.workspace_id || '_inbox' OR ${alias}.id = 'nb_inbox' OR ${alias}.slug = 'inbox')`;
export type NotebookRow = {
id: string;
parent_id: string | null;
name: string;
slug: string | null;
icon: string | null;
color: string | null;
sort_order: number;
memo_count: number | null;
last_memo_updated_at: string | null;
created_at: string;
updated_at: string;
};
export const mapNotebook = (row: NotebookRow): Notebook => ({
id: row.id,
parentId: row.parent_id,
name: row.name,
slug: row.slug,
icon: row.icon,
color: row.color,
sortOrder: row.sort_order,
memoCount: row.memo_count ?? 0,
lastMemoUpdatedAt: row.last_memo_updated_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
export const notebookSelectSql = (tail: string) => `
SELECT n.id,
n.parent_id,
n.name,
n.slug,
n.icon,
n.color,
n.sort_order,
COUNT(m.id) AS memo_count,
MAX(m.updated_at) AS last_memo_updated_at,
n.created_at,
n.updated_at
FROM notebooks n
LEFT JOIN memos m ON m.notebook_id = n.id AND m.is_deleted = 0
${tail}
`;
export const listNotebooks = async (db: DatabaseAdapter, workspaceId: string): Promise<Notebook[]> => {
const rows = await db
.prepare(
notebookSelectSql(
`WHERE n.workspace_id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at
ORDER BY n.parent_id IS NOT NULL, n.sort_order ASC, n.name ASC`
)
)
.bind(workspaceId).all<NotebookRow>();
return rows.results.map(mapNotebook);
};
const normalizeNotebookLookupName = (value: string) => value.trim().toLocaleLowerCase("en-US");
export const findNotebooks = async (
db: DatabaseAdapter,
workspaceId: string,
options: { name: string; parentId?: string | null; exact: boolean; limit: number },
) => {
const query = normalizeNotebookLookupName(options.name);
const notebooks = await listNotebooks(db, workspaceId);
return notebooks
.filter((notebook) => {
if (options.parentId !== undefined && notebook.parentId !== options.parentId) {
return false;
}
const name = normalizeNotebookLookupName(notebook.name);
return options.exact ? name === query : name.includes(query);
})
.slice(0, options.limit);
};
export const resolveNotebookPath = async (db: DatabaseAdapter, workspaceId: string, path: string) => {
const segments = path.split("/").map((segment) => segment.trim()).filter(Boolean);
if (segments.length === 0) {
throw new AppError("invalid_params", "path must contain at least one notebook name", 400);
}
const notebooks = await listNotebooks(db, workspaceId);
const matched: Notebook[] = [];
let parentId: string | null = null;
for (const [index, segment] of segments.entries()) {
const normalizedSegment = normalizeNotebookLookupName(segment);
const candidates = notebooks.filter(
(notebook) => notebook.parentId === parentId && normalizeNotebookLookupName(notebook.name) === normalizedSegment,
);
if (candidates.length !== 1) {
return {
resolved: false,
path,
segments,
matched,
failedAt: index,
failedSegment: segment,
reason: candidates.length === 0 ? "not_found" : "ambiguous",
candidates,
};
}
matched.push(candidates[0]);
parentId = candidates[0].id;
}
return {
resolved: true,
path,
segments,
notebook: matched.at(-1),
matched,
};
};
export const getNotebook = async (db: DatabaseAdapter, workspaceId: string, id: string): Promise<Notebook | null> => {
const row = await db
.prepare(
notebookSelectSql(
`WHERE n.id = ? AND n.workspace_id = ? AND n.is_deleted = 0
GROUP BY n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at`
)
)
.bind(id, workspaceId)
.first<NotebookRow>();
return row ? mapNotebook(row) : null;
};
export const createNotebookRecord = async (
db: DatabaseAdapter,
workspaceId: string,
input: NotebookCreateInput & { sortOrder?: number },
actor: AuditActor
) => {
const parentId = input.parentId ?? null;
if (parentId && !(await getNotebook(db, workspaceId, parentId))) {
throw new AppError("not_found", "Parent notebook not found", 404);
}
const id = createId("nb");
const now = isoNow();
const sortOrder = input.sortOrder ?? Date.now();
await db.batch([
db
.prepare(
`INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(id, workspaceId, parentId, input.name, slugify(input.name), sortOrder, now, now),
auditStatement(db, actor.actorType, actor.actorId, "notebook.create", "notebook", id, {
name: input.name,
parentId,
sortOrder,
}),
]);
const notebook = await getNotebook(db, workspaceId, id);
if (!notebook) {
throw new AppError("not_found", "Notebook not found after create", 404);
}
return notebook;
};
export const updateNotebookRecord = async (
db: DatabaseAdapter,
workspaceId: string,
id: string,
input: { name?: string; parentId?: string | null; sortOrder?: number },
actor: AuditActor
) => {
const current = await getNotebook(db, workspaceId, id);
if (!current) {
throw new AppError("not_found", "Notebook not found", 404);
}
const nextName = input.name ?? current.name;
const nextParentId = input.parentId === undefined ? current.parentId : input.parentId;
const nextSortOrder = input.sortOrder ?? current.sortOrder;
const nextSlug = isInboxNotebook(current, workspaceId) ? "inbox" : slugify(nextName);
const now = isoNow();
if (nextParentId === id) {
throw new AppError("bad_request", "Notebook cannot be its own parent", 400);
}
if (nextParentId) {
const parent = await getNotebook(db, workspaceId, nextParentId);
if (!parent) {
throw new AppError("not_found", "Parent notebook not found", 404);
}
if (await isNotebookDescendant(db, workspaceId, nextParentId, id)) {
throw new AppError("notebook_cycle", "Notebook cannot be moved into its own descendant.", 409);
}
}
await db.batch([
db
.prepare(
`UPDATE notebooks
SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ?
WHERE id = ? AND workspace_id = ? AND is_deleted = 0`
)
.bind(nextName, nextSlug, nextParentId ?? null, nextSortOrder, now, id, workspaceId),
auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input),
]);
const notebook = await getNotebook(db, workspaceId, id);
if (!notebook) {
throw new AppError("not_found", "Notebook not found after update", 404);
}
return notebook;
};
const NOTEBOOK_DELETE_ID_CHUNK = 80;
const chunkValues = <T>(values: T[], size: number) => {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size));
}
return chunks;
};
// Active notebooks in this subtree. The anchor may already be deleted so a
// retried parent delete can still remove descendants that were left behind.
const listActiveNotebookTree = async (db: DatabaseAdapter, workspaceId: string, id: string) => {
const rows = await db.prepare(
`WITH RECURSIVE tree(id) AS (
SELECT id FROM notebooks WHERE workspace_id = ? AND id = ?
UNION ALL
SELECT n.id
FROM notebooks n
INNER JOIN tree t ON n.parent_id = t.id
WHERE n.workspace_id = ?
)
SELECT n.id, n.slug
FROM notebooks n
INNER JOIN tree t ON t.id = n.id
WHERE n.workspace_id = ? AND n.is_deleted = 0`
).bind(workspaceId, id, workspaceId, workspaceId).all<{ id: string; slug: string | null }>();
return rows.results;
};
const countActiveMemosInNotebooks = async (db: DatabaseAdapter, workspaceId: string, notebookIds: string[]) => {
let total = 0;
for (const chunk of chunkValues(notebookIds, NOTEBOOK_DELETE_ID_CHUNK)) {
const placeholders = chunk.map(() => "?").join(", ");
const row = await db.prepare(
`SELECT COUNT(*) AS count FROM memos WHERE workspace_id = ? AND is_deleted = 0 AND notebook_id IN (${placeholders})`
).bind(workspaceId, ...chunk).first<{ count: number }>();
total += Number(row?.count ?? 0);
}
return total;
};
export const deleteNotebookRecord = async (
db: DatabaseAdapter,
workspaceId: string,
id: string,
actor: AuditActor
) => {
const current = await db.prepare(
`SELECT id, slug, is_deleted FROM notebooks WHERE id = ? AND workspace_id = ?`
).bind(id, workspaceId).first<{ id: string; slug: string | null; is_deleted: number }>();
if (!current) throw new AppError("not_found", "Notebook not found", 404);
if (isInboxNotebook(current, workspaceId)) {
if (current.is_deleted) return;
throw new AppError("bad_request", "等待分类不能删除。", 400);
}
const tree = await listActiveNotebookTree(db, workspaceId, id);
if (tree.length === 0) return;
if (tree.some((notebook) => isInboxNotebook(notebook, workspaceId))) {
throw new AppError("bad_request", "等待分类不能删除。", 400);
}
const notebookIds = tree.map((notebook) => notebook.id);
// Empty child notebooks are removed with the parent. Notes anywhere in the
// subtree block the whole delete so a parent click cannot orphan them.
if (await countActiveMemosInNotebooks(db, workspaceId, notebookIds) > 0) {
throw new AppError(
"notebook_not_empty",
"Move or delete notes in this notebook and its child notebooks before deleting it.",
409
);
}
const now = isoNow();
const inboxId = workspaceInboxId(workspaceId);
const statements = chunkValues(notebookIds, NOTEBOOK_DELETE_ID_CHUNK).map((chunk) => {
const placeholders = chunk.map(() => "?").join(", ");
return db.prepare(
`UPDATE notebooks
SET is_deleted = 1, deleted_at = ?, updated_at = ?
WHERE workspace_id = ? AND is_deleted = 0 AND id IN (${placeholders})
AND (slug IS NULL OR slug <> 'inbox') AND id <> 'nb_inbox' AND id <> ?`
).bind(now, now, workspaceId, ...chunk, inboxId);
});
statements.push(auditStatement(db, actor.actorType, actor.actorId, "notebook.delete", "notebook", id, {
notebookIds,
}));
await db.batch(statements);
};
export const restoreNotebookRecord = async (
db: DatabaseAdapter,
workspaceId: string,
id: string,
actor: AuditActor
) => {
const current = await db.prepare(
`SELECT id FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 1`
).bind(id, workspaceId).first<{ id: string }>();
if (!current) throw new AppError("not_found", "Deleted notebook not found", 404);
const now = isoNow();
await db.batch([
db.prepare(
`UPDATE notebooks SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ? AND workspace_id = ?`
).bind(now, id, workspaceId),
auditStatement(db, actor.actorType, actor.actorId, "notebook.restore", "notebook", id, {}),
]);
const notebook = await getNotebook(db, workspaceId, id);
if (!notebook) throw new AppError("not_found", "Notebook not found after restore", 404);
return notebook;
};
export const isNotebookDescendant = async (
db: DatabaseAdapter,
workspaceId: string,
candidateId: string,
ancestorId: string
) => {
const row = await db
.prepare(
`WITH RECURSIVE descendants(id) AS (
SELECT id
FROM notebooks
WHERE workspace_id = ? AND parent_id = ? AND is_deleted = 0
UNION ALL
SELECT n.id
FROM notebooks n
INNER JOIN descendants d ON n.parent_id = d.id
WHERE n.workspace_id = ? AND n.is_deleted = 0
)
SELECT id
FROM descendants
WHERE id = ?
LIMIT 1`
)
.bind(workspaceId, ancestorId, workspaceId, candidateId)
.first<{ id: string }>();
return Boolean(row);
};