dorfteich/apps/api/src/pages/pages.service.ts
Claude Fable 5 3f7190ebcc #153: Stabile Task-IDs + Toggle-Rückschreibpfad über den Collab-Server
task_item bekommt ein optionales id-Attr (default null — Bestandsdocs
bleiben gültig), durchgereicht in toDOM/parseDOM und dem Lese-HTML;
der Editor vergibt/entdoppelt IDs lazy per appendTransaction
(TaskItemIds-Extension, auch gegen Copy/Paste). Neuer Kanal
TASK_TOGGLE_CHANNEL; POST /pages/:id/tasks/:taskId {checked} prüft
Schreibrecht, registriert den Toggler als pending contributor und
feuert pg_notify; neuer collab task-toggle-listener (Struktur =
restore-listener) öffnet eine DirectConnection und flippt das
checked-Attribut in einer Transaktion — offene Editoren konvergieren,
unbekannte taskId = geloggter No-op. DB-Test (NOTIFY-Payload,
Attribution, 403/404/400).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:03:16 +02:00

602 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CollabTokenResponse,
CreatePageInput,
MAX_PAGE_DEPTH,
OutlineEntry,
PageDeleteMode,
PageListItemView,
PageListQuery,
PageStateView,
PageView,
PluginPageSummary,
RepositionPageInput,
SidebarSortMode,
TASK_TOGGLE_CHANNEL,
TaskToggleRequest,
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;
}
/**
* Toggles one task-list checkbox (issue #153). The collab server owns the
* live document, so this only records the toggler as a pending contributor
* (version attribution) and emits the NOTIFY — the listener applies the
* attribute change as a normal edit and every open client converges.
*/
async toggleTask(user: User, pageId: string, taskId: string, checked: boolean): Promise<void> {
await this.findLivePage(pageId);
await this.prisma.pagePendingContributor.upsert({
where: { pageId_userId: { pageId, userId: user.id } },
update: {},
create: { pageId, userId: user.id },
});
const payload: TaskToggleRequest = { pageId, taskId, checked, userId: user.id };
await this.prisma
.$executeRaw`SELECT pg_notify(${TASK_TOGGLE_CHANNEL}, ${JSON.stringify(payload)})`;
}
/** 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. Optional time
* filters (issue #148) narrow to pages created/updated at or after an
* instant. */
async list(
user: User | null,
pondId: string,
query?: PageListQuery,
): 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,
...(query?.createdSince ? { createdAt: { gte: query.createdSince } } : {}),
...(query?.updatedSince ? { updatedAt: { gte: query.updatedSince } } : {}),
},
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,
presetSlug?: string,
): Promise<Page> {
return this.insertPage(user, pondId, title, state, parentId, presetSlug);
}
/**
* 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,
presetSlug?: string,
): 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);
// A batch import (#117) reserves its slugs up front against the pond
// snapshot batch — the unique index stays the final arbiter.
const slug = presetSlug ?? (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);
// Seed the page's own outgoing links (issue #117): pages born with
// content (imports) would otherwise stay invisible to backlinks and the
// graph until their first collab save — collab rewrites these rows on
// every later save, exactly as it does for hand-typed links.
if (content.wikilinkSlugs.length > 0) {
await this.indexOutgoingLinks(pond.id, page.id, content.wikilinkSlugs);
}
// 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;
}
/**
* Insert the outgoing `page_links` rows of a freshly created page (issue
* #117), resolving each target slug against the pond's live pages — the
* same shape the collab persistence writes on every save (#47). Insert-only:
* a brand-new page has no rows to replace.
*/
private async indexOutgoingLinks(pondId: string, pageId: string, slugs: string[]): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO page_links (id, from_page_id, to_page_id, target_slug)
SELECT gen_random_uuid(), ${pageId}, target.id, s.link_slug
FROM unnest(${slugs}::text[]) AS s(link_slug)
LEFT JOIN pages AS target
ON target.pond_id = ${pondId} AND target.slug = s.link_slug
AND target.deleted_at IS NULL`;
}
/**
* 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',
);
}
}