dorfteich/apps/api/src/pages/pages.service.ts
Claude Fable 5 ffcc337ed0
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
Page-tree parity for the public REST API and MCP (#110)
The slug-based machine surfaces now see and shape the hierarchy:

- REST: page list/detail carry parent (the parent page's slug, nulled
  when the token's user may not read it — same no-leak rule as the
  internal list); create accepts parent; PATCH accepts parent
  (slug nests, null moves to the top level, appended at the end of the
  new sibling group via the new PagesService.moveToEnd). Cycle/depth
  refusals keep their regular error codes. OpenAPI updated.
- MCP: list_pages returns parent, create_page takes an optional parent
  slug, update_page moves with parent (slug|null); tool errors carry
  the api code (page_cycle covered in the e2e pack).
- ZIP export deliberately stays flat — noted in features.md; the
  hierarchy is organizational only.

e2e: REST pack covers nested create, list shape, move/root-move, 409
page_cycle, 404 unknown parent; MCP pack covers nested create, list
parent, and the cycle tool error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:49:52 +02:00

543 lines
21 KiB
TypeScript

import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CollabTokenResponse,
CreatePageInput,
MAX_PAGE_DEPTH,
OutlineEntry,
PageDeleteMode,
PageListItemView,
PageStateView,
PageView,
PluginPageSummary,
RepositionPageInput,
SidebarSortMode,
TreeItem,
UpdatePageInput,
collectSubtreeIds,
nodeDepth,
pondSettingsSchema,
slugify,
subtreeHeight,
} from '@dorfteich/shared';
import { signCollabToken } from '@dorfteich/shared/token-crypto';
import { Page, Prisma, User } from '@prisma/client';
import { generateKeyBetween } from 'fractional-indexing';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { WatchesService } from '../watches/watches.service';
import { SearchProvider } from '../search/search.provider';
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
function contentCacheData(
content: DerivedPageContent,
): Prisma.PageContentCacheCreateWithoutPageInput {
return {
plainText: content.plainText,
markdown: content.markdown,
html: content.html,
outline: content.outline as unknown as Prisma.InputJsonValue,
};
}
/** Collaboration tokens are short-lived; the client re-fetches on reconnect
* (realtime-collaboration.md). 60 s is the ceiling the story specifies. */
const COLLAB_TOKEN_TTL_SECONDS = 60;
@Injectable()
export class PagesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly logger: PinoLogger,
private readonly config: AppConfig,
private readonly search: SearchProvider,
private readonly watches: WatchesService,
) {
this.logger.setContext(PagesService.name);
}
viewOf(page: Page): PageView {
return {
id: page.id,
pondId: page.pondId,
parentId: page.parentId,
title: page.title,
slug: page.slug,
sortKey: page.sortKey,
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
deletedAt: page.deletedAt?.toISOString() ?? null,
};
}
stateViewOf(page: Page): PageStateView {
return { ...this.viewOf(page), state: Buffer.from(page.ydocState).toString('base64') };
}
/** Deterministic unique slug within one pond (mirrors PondsService). */
private async generateUniqueSlugInPond(pondId: string, base: string): Promise<string> {
const slug = slugify(base) || 'page';
const taken = new Set(
(
await this.prisma.page.findMany({
where: { pondId, OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
select: { slug: true },
})
).map((row) => row.slug),
);
if (!taken.has(slug)) return slug;
for (let n = 2; ; n += 1) {
const candidate = `${slug}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
/** Loads a live page; permission is the guard's job since #52. */
private async findLivePage(id: string): Promise<Page> {
const page = await this.prisma.page.findFirst({ where: { id, deletedAt: null } });
if (!page) throw new NotFoundException();
return page;
}
/** The live `{id, parentId}` skeleton of a pond — input to the tree walks
* (issue #106). Trashed pages keep their `parentId` but never count here. */
private async livePageTree(pondId: string): Promise<TreeItem[]> {
return this.prisma.page.findMany({
where: { pondId, deletedAt: null },
select: { id: true, parentId: true },
});
}
/**
* Validates a page's new parent (issue #106, mirroring `LabelsService`): it
* must be a live page of the pond (unknown/foreign ids read as 404), may not
* sit inside the moved page's own subtree (`page_cycle`), and the moved
* subtree must stay within {@link MAX_PAGE_DEPTH} (`page_depth_exceeded`).
* `movedId` is null when creating — the new page is a leaf of height 1.
*/
private assertValidParent(tree: TreeItem[], parentId: string, movedId: string | null): void {
if (!tree.some((p) => p.id === parentId)) throw new NotFoundException();
if (movedId && collectSubtreeIds(tree, movedId).has(parentId)) {
throw new ConflictException({ code: 'page_cycle' });
}
const height = movedId ? subtreeHeight(tree, movedId) : 1;
if (nodeDepth(tree, parentId) + height > MAX_PAGE_DEPTH) {
throw new ConflictException({
code: 'page_depth_exceeded',
details: { max: [String(MAX_PAGE_DEPTH)] },
});
}
}
private static readonly SORT_ORDER: Record<SidebarSortMode, Prisma.PageOrderByWithRelationInput> =
{
alpha: { title: 'asc' },
created: { createdAt: 'asc' },
// Manual reordering (drag-and-drop) arrives with #45; the fractional
// `sortKey` already reflects creation order in the meantime.
manual: { sortKey: 'asc' },
};
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
* each with its assigned label ids for chips and filtering (issue #44).
* Filtered to the pages the user may read (issue #52) — a label- or
* page-scoped reader sees only their slice of the pond. */
async list(user: User, pondId: string): Promise<PageListItemView[]> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
const settings = pondSettingsSchema.parse(pond.settings ?? {});
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
include: { labels: { select: { labelId: true } } },
});
const readable = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
return pages
.filter((page) => readable.has(page.id))
.map((page) => ({
...this.viewOf(page),
// A parent the caller may not read is nulled (issue #106): the child
// shows at the root and the hidden page's id never leaks.
parentId: page.parentId && readable.has(page.parentId) ? page.parentId : null,
labelIds: page.labels.map((l) => l.labelId),
}));
}
/** `readPond.listPages` for the plugin API (issue #77): the viewer-readable
* pages with their label *names* — pageTool plugins filter on these and
* never see ids they could not resolve anyway. */
async pluginPageSummaries(user: User, pondId: string): Promise<PluginPageSummary[]> {
const [pages, labels] = await Promise.all([
this.list(user, pondId),
this.prisma.label.findMany({ where: { pondId }, select: { id: true, name: true } }),
]);
const nameById = new Map(labels.map((label) => [label.id, label.name]));
return pages.map((page) => ({
id: page.id,
title: page.title,
slug: page.slug,
labels: page.labelIds
.map((id) => nameById.get(id))
.filter((name): name is string => Boolean(name))
.sort(),
}));
}
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
const page = await this.insertPage(
user,
pondId,
input.title,
emptyPageState(),
input.parentId ?? null,
);
// Auto-watch own pages (issue #93) — preference-gated, never fatal.
await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {});
return this.viewOf(page);
}
/**
* Create a page from a prepared Yjs state (issue #63 import): the imported
* document is already a full Yjs state whose fragment the editor binds to, so
* an opening client sees the converted content immediately. Same invariants
* as {@link create} — unique slug, appended sort key, derived content cache,
* phantom-link resolution, search indexing. Returns the persisted row so the
* caller (the import worker) can link the document's media to it.
*/
async createWithState(
user: User,
pondId: string,
title: string,
state: Uint8Array<ArrayBuffer>,
parentId: string | null = null,
): Promise<Page> {
return this.insertPage(user, pondId, title, state, parentId);
}
/**
* Move a page to a new parent, appended at the end of the pond order
* (issue #110): the placement clients cannot express when they only speak
* slugs (public API, MCP). Same validation path as {@link reposition}.
*/
async moveToEnd(user: User, id: string, parentId: string | null): Promise<PageView> {
const page = await this.findLivePage(id);
const last = await this.prisma.page.findFirst({
where: { pondId: page.pondId, deletedAt: null, id: { not: id } },
orderBy: { sortKey: 'desc' },
select: { id: true },
});
return this.reposition(user, id, { afterId: last?.id ?? null, beforeId: null, parentId });
}
private async insertPage(
user: User,
pondId: string,
title: string,
state: Uint8Array<ArrayBuffer>,
parentId: string | null = null,
): Promise<Page> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
if (parentId) this.assertValidParent(await this.livePageTree(pond.id), parentId, null);
const slug = await this.generateUniqueSlugInPond(pond.id, title);
const last = await this.prisma.page.findFirst({
where: { pondId: pond.id },
orderBy: { sortKey: 'desc' },
select: { sortKey: true },
});
const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
const content = deriveContent(state);
const page = await this.prisma.page.create({
data: {
pondId: pond.id,
parentId,
title,
slug,
sortKey,
ydocState: state,
createdBy: user.id,
contentCache: { create: contentCacheData(content) },
},
});
// A new page may satisfy phantom wikilinks that referenced its slug (#47).
await this.resolvePhantomLinks(pond.id, slug, page.id);
// Index the page so a title-only match is findable immediately (#49).
await this.search.indexPage(page.id);
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
return page;
}
/**
* Point phantom `page_links` (unresolved, `to_page_id` null) whose
* `target_slug` matches `slug` within the pond at `pageId` (issue #47).
* Called when a page is created or renamed to a slug that pages already link
* to. Backlinks store the target's id, so once resolved a later target rename
* keeps them connected.
*/
private async resolvePhantomLinks(pondId: string, slug: string, pageId: string): Promise<void> {
await this.prisma.$executeRaw`
UPDATE page_links SET to_page_id = ${pageId}
WHERE to_page_id IS NULL AND target_slug = ${slug}
AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`;
}
async getState(_user: User, id: string): Promise<PageStateView> {
const page = await this.findLivePage(id);
return this.stateViewOf(page);
}
/**
* Mint a collaboration token for a page (issue #34). The permission check
* runs in the api — the guard requires read access, and the collab server
* never sees session cookies (ADR 0003). `mode` is `rw` for who may write
* the page per the real grant resolution (issue #52) and `ro` otherwise.
*
* `user` is `null` for an anonymous visitor on a public page (issue #53): the
* guard has already granted read access via a `public` grant, so they receive
* an `ro` token with a `null` subject.
*/
async issueCollabToken(user: User | null, id: string): Promise<CollabTokenResponse> {
const page = await this.findLivePage(id);
const canWrite = await this.permissions.canAccessPage(user, page, 'write');
const mode = canWrite ? 'rw' : 'ro';
const userId = user?.id ?? null;
const token = signCollabToken(
{ userId, pageId: page.id, mode },
this.config.env.COLLAB_TOKEN_SECRET,
COLLAB_TOKEN_TTL_SECONDS,
);
// Debug level, and deliberately without the token value (issue #34).
this.logger.debug({ pageId: page.id, userId, mode }, 'issued collab token');
return { token, mode, expiresInSeconds: COLLAB_TOKEN_TTL_SECONDS };
}
/** The trashed-page hint for editors (issue #31) moved into the guard. */
async getStateBySlug(_user: User, pondId: string, slug: string): Promise<PageStateView> {
const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } });
if (!page) throw new NotFoundException();
return this.stateViewOf(page);
}
async update(_user: User, id: string, input: UpdatePageInput): Promise<PageView> {
const page = await this.findLivePage(id);
let slug = page.slug;
if (input.slug !== undefined) {
const normalized = slugify(input.slug) || page.slug;
if (normalized !== page.slug) {
const clash = await this.prisma.page.findFirst({
where: { pondId: page.pondId, slug: normalized, id: { not: page.id } },
select: { id: true },
});
if (clash) throw new ConflictException({ code: 'slug_taken' });
}
slug = normalized;
}
const updated = await this.prisma.page.update({
where: { id: page.id },
data: { title: input.title, slug },
});
// Renaming to a slug pages already link to resolves those phantom links (#47).
if (slug !== page.slug) await this.resolvePhantomLinks(page.pondId, slug, page.id);
// A changed title changes the (weighted) search entry (#49).
if (input.title !== undefined && input.title !== page.title) {
await this.search.indexPage(page.id);
}
return this.viewOf(updated);
}
/**
* Reposition a page in the manual sidebar order (issue #45) and/or move it to
* a new parent in the page tree (issue #106). Recomputes only the moved
* page's `sort_key` to a value between its two new neighbours; when that key
* would grow too long (or the client's neighbours are stale) the whole pond
* is rebalanced to evenly-spaced keys with the page dropped at the target
* slot. Per-sibling-group order needs no extra machinery: a key between two
* siblings keeps the group's relative order under the pond-wide sequence.
* The order is server-authoritative, so every viewer sees the same sequence.
* Requires write access; the sort mode does not have to be `manual` (the key
* is stored regardless, just not applied in other modes).
*/
async reposition(_user: User, id: string, input: RepositionPageInput): Promise<PageView> {
const page = await this.findLivePage(id);
const { afterId, beforeId } = input;
if (afterId === id || beforeId === id) {
throw new ConflictException({ code: 'bad_request' });
}
// An absent `parentId` leaves the parent untouched; a present one (page id
// or null-for-root) reparents atomically with the placement (issue #106).
const parentId = input.parentId === page.parentId ? undefined : input.parentId;
if (parentId != null) {
this.assertValidParent(await this.livePageTree(page.pondId), parentId, id);
}
const [afterPage, beforePage] = await Promise.all([
afterId
? this.prisma.page.findFirst({
where: { id: afterId, pondId: page.pondId, deletedAt: null },
select: { sortKey: true },
})
: null,
beforeId
? this.prisma.page.findFirst({
where: { id: beforeId, pondId: page.pondId, deletedAt: null },
select: { sortKey: true },
})
: null,
]);
if (afterId && !afterPage) throw new NotFoundException();
if (beforeId && !beforePage) throw new NotFoundException();
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
if (key !== null) {
const updated = await this.prisma.page.update({
where: { id },
data: { sortKey: key, parentId },
});
return this.viewOf(updated);
}
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
}
/**
* Reassign evenly-spaced `sort_key`s to every page in the pond, with the
* moved page inserted at the slot implied by `afterId`/`beforeId`. Runs in one
* transaction so the order is never observed half-rebalanced.
*/
private async rebalanceAndPlace(
pondId: string,
movedId: string,
afterId: string | null,
beforeId: string | null,
parentId?: string | null,
): Promise<PageView> {
return this.prisma.$transaction(async (tx) => {
const pages = await tx.page.findMany({
where: { pondId, deletedAt: null },
orderBy: { sortKey: 'asc' },
select: { id: true },
});
const order = pages.map((p) => p.id).filter((pid) => pid !== movedId);
let index = order.length;
if (afterId) index = order.indexOf(afterId) + 1;
else if (beforeId) index = Math.max(0, order.indexOf(beforeId));
order.splice(index, 0, movedId);
const keys = evenlySpacedKeys(order.length);
await Promise.all(
order.map((pid, i) =>
tx.page.update({
where: { id: pid },
data: { sortKey: keys[i]!, ...(pid === movedId ? { parentId } : {}) },
}),
),
);
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
});
}
/** Markdown export (issue #30) — serves the already-derived
* `page_content_cache.markdown` (refreshed on every state save, #23)
* rather than re-decoding the Yjs state, so export always matches what
* the app itself considers the page's current Markdown representation. */
async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> {
const page = await this.findLivePage(id);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return { slug: page.slug, markdown: cache?.markdown ?? '' };
}
/** The page's heading outline from the content cache (plugin API #74).
* Permission is enforced by the caller's page-read guard. */
async outline(id: string): Promise<OutlineEntry[]> {
const page = await this.findLivePage(id);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return (cache?.outline as unknown as OutlineEntry[] | undefined) ?? [];
}
/** Minimal page metadata for the plugin API (#74): id, title, pond, slug. */
async meta(id: string): Promise<{ id: string; title: string; pondId: string; slug: string }> {
const page = await this.findLivePage(id);
return { id: page.id, title: page.title, pondId: page.pondId, slug: page.slug };
}
/**
* Trash a page (issue #23; delete modes issue #107). `promote` (default)
* re-attaches the page's live children to its parent so nothing else leaves
* the sidebar; `subtree` trashes every live descendant with the same
* timestamp — gated on write permission over all of them (403 otherwise;
* trash access is write capability, ADR 0013). Trashed pages keep their
* `parentId`; restore re-attaches to the nearest live ancestor.
*/
async softDelete(user: User, id: string, mode: PageDeleteMode = 'promote'): Promise<void> {
const page = await this.findLivePage(id);
const tree = await this.livePageTree(page.pondId);
const descendantIds = [...collectSubtreeIds(tree, page.id)].filter((pid) => pid !== page.id);
if (mode === 'subtree' && descendantIds.length > 0) {
const descendants = await this.prisma.page.findMany({
where: { id: { in: descendantIds } },
include: { labels: { select: { labelId: true } } },
});
const writable = await this.permissions.filterPages(
user,
page.pondId,
descendants.map((d) => ({ id: d.id, labelIds: d.labels.map((l) => l.labelId) })),
'write',
);
// No partial deletes: one unwritable descendant blocks the whole subtree.
if (descendants.some((d) => !writable.has(d.id))) {
throw new ForbiddenException({ code: 'forbidden' });
}
}
const deletedAt = new Date();
await this.prisma.$transaction(async (tx) => {
if (mode === 'subtree') {
await tx.page.updateMany({
where: { id: { in: [page.id, ...descendantIds] }, deletedAt: null },
data: { deletedAt, deletedBy: user.id },
});
} else {
await tx.page.updateMany({
where: { parentId: page.id, deletedAt: null },
data: { parentId: page.parentId },
});
await tx.page.update({
where: { id: page.id },
data: { deletedAt, deletedBy: user.id },
});
}
});
this.logger.info(
{
pageId: id,
userId: user.id,
mode,
descendants: mode === 'subtree' ? descendantIds.length : 0,
},
'audit: page trashed',
);
}
}