dorfteich/apps/api/src/pages/pages.service.ts
Claude Fable 5 68497046e9
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m1s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 9m6s
CI / Import/export fidelity gate (pull_request) Successful in 1m8s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 24s
CI / Lint, typecheck, test (push) Successful in 6m37s
CD / Deploy to Test (push) Successful in 12s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Has been cancelled
#210: mark the Markdown ZIP export with frontmatter, imprint and manifest
A classified page's .md carries the level in YAML frontmatter AND the
marking line at top and bottom; unclassified files are byte-identical to
before. Every pond archive (incl. the per-pond folders of the account
data export) ships a manifest.json listing each file with its level and
stating the highest level once at archive level — media inherits the
highest classification among the readable pages referencing it
(fail-closed). Round trip: the importer recognizes exactly our
frontmatter block, strips it plus the imprint lines, and creates the
page at least at the imported level (content must not escape its marking
by traveling through a ZIP) — pinned by unit and e2e round-trip tests.
Foreign frontmatter passes through unchanged; the Obsidian vault import
keeps its own frontmatter modes.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:13:53 +02:00

736 lines
29 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,
PageClassification,
PageDeleteMode,
PageListItemView,
PageListQuery,
PageStateView,
PageView,
PluginPageSummary,
RepositionPageInput,
SidebarSortMode,
TASK_TOGGLE_CHANNEL,
TaskToggleRequest,
TreeItem,
UpdatePageInput,
classificationRank,
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 { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.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;
/** DB ↔ API spelling of the classification enum (ADR 0022): Prisma stores
* SCREAMING_SNAKE, every representation outside the schema is lowercase. */
function toDbClassification(value: PageClassification): Page['classification'] {
return value === 'vs_nfd' ? 'VS_NFD' : 'UNCLASSIFIED';
}
function fromDbClassification(value: Page['classification']): PageClassification {
return value.toLowerCase() as PageClassification;
}
@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,
private readonly settings: InstanceSettingsService,
private readonly audit: AuditService,
) {
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,
classification: fromDbClassification(page.classification),
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,
atLeastClassification: PageClassification | null = null,
): Promise<Page> {
return this.insertPage(user, pondId, title, state, parentId, presetSlug, atLeastClassification);
}
/**
* 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,
atLeastClassification: PageClassification | 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);
// 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);
// A new page starts at the instance-wide default level (ADR 0022, #204),
// raised to its parent's level when that is higher (#205) — and to an
// imported document's own level (#210): content must not escape its
// marking by traveling through an export/import. A subpage of classified
// content must never begin unmarked.
let classification: PageClassification = await this.settings.get(
'classification.newPageDefault',
);
if (
atLeastClassification &&
classificationRank(atLeastClassification) > classificationRank(classification)
) {
classification = atLeastClassification;
}
if (parentId) {
const parent = await this.prisma.page.findUniqueOrThrow({
where: { id: parentId },
select: { classification: true },
});
const parentLevel = fromDbClassification(parent.classification);
if (classificationRank(parentLevel) > classificationRank(classification)) {
classification = parentLevel;
}
}
const page = await this.prisma.page.create({
data: {
pondId: pond.id,
parentId,
title,
slug,
sortKey,
classification: toDbClassification(classification),
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 = await 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;
}
// Classification change (#205, ADR 0022): raising is ordinary editorial
// work (the write guard has run); LOWERING is the sensitive direction and
// needs the dedicated capability from the central permission model.
// Both directions are audited with old value, new value, actor and page.
const currentLevel = fromDbClassification(page.classification);
const targetLevel = input.classification;
const levelChanges = targetLevel !== undefined && targetLevel !== currentLevel;
if (levelChanges && classificationRank(targetLevel) < classificationRank(currentLevel)) {
const mayLower = await this.permissions.canLowerClassification(user, page.pondId);
if (!mayLower) throw new ForbiddenException({ code: 'classification_lower_forbidden' });
}
const updated = await this.prisma.page.update({
where: { id: page.id },
data: {
title: input.title,
slug,
...(levelChanges ? { classification: toDbClassification(targetLevel) } : {}),
},
});
if (levelChanges) {
const raised = classificationRank(targetLevel) > classificationRank(currentLevel);
await this.audit.record({
action: raised ? 'page.classification_raised' : 'page.classification_lowered',
actorId: user.id,
targetType: 'page',
targetId: page.id,
details: { from: currentLevel, to: targetLevel, trigger: 'edit', pondId: page.pondId },
});
}
// 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;
// Moving under a higher-classified parent raises the moved subtree to
// that level (#205, ADR 0022) — never the other way around: a move can
// raise but must not lower as a side effect. Plan before placing (the
// tree walk needs the pre-move tree), apply after.
let raisePlan: { subtreeIds: string[]; to: PageClassification } | null = null;
if (parentId != null) {
const tree = await this.livePageTree(page.pondId);
this.assertValidParent(tree, parentId, id);
const parent = await this.prisma.page.findUniqueOrThrow({
where: { id: parentId },
select: { classification: true },
});
const parentLevel = fromDbClassification(parent.classification);
if (classificationRank(parentLevel) > 0) {
raisePlan = { subtreeIds: [...collectSubtreeIds(tree, id)], to: parentLevel };
}
}
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);
let placed: PageView;
if (key !== null) {
const updated = await this.prisma.page.update({
where: { id },
data: { sortKey: key, parentId },
});
placed = this.viewOf(updated);
} else {
placed = await this.rebalanceAndPlace(page.pondId, id, afterId, beforeId, parentId);
}
if (raisePlan) {
await this.raiseSubtree(user, page.pondId, raisePlan.subtreeIds, raisePlan.to);
return this.viewOf(await this.prisma.page.findUniqueOrThrow({ where: { id } }));
}
return placed;
}
/**
* Raise every page of a moved subtree that sits below `to` (#205). The
* whole subtree is covered, not just its root: descendants must not end
* up below the level of the tree above them. With the current two-level
* vocabulary "below `to`" is exactly `UNCLASSIFIED` rows. Every raised
* page gets its own audit record (old value, new value, actor, page).
*/
private async raiseSubtree(
user: User,
pondId: string,
subtreeIds: string[],
to: PageClassification,
): Promise<void> {
const below = await this.prisma.page.findMany({
where: { id: { in: subtreeIds }, classification: { not: toDbClassification(to) } },
select: { id: true, classification: true },
});
if (below.length === 0) return;
await this.prisma.page.updateMany({
where: { id: { in: below.map((p) => p.id) } },
data: { classification: toDbClassification(to) },
});
for (const raised of below) {
await this.audit.record({
action: 'page.classification_raised',
actorId: user.id,
targetType: 'page',
targetId: raised.id,
details: {
from: fromDbClassification(raised.classification),
to,
trigger: 'move',
pondId,
},
});
}
}
/**
* 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 },
});
}
});
// Trashed content leaves the search index (issue #195); the query-side
// deleted_at guards in the provider stay as the second layer.
const trashedIds = mode === 'subtree' ? [page.id, ...descendantIds] : [page.id];
for (const trashedId of trashedIds) {
await this.search.removePage(trashedId);
}
this.logger.info(
{
pageId: id,
userId: user.id,
mode,
descendants: mode === 'subtree' ? descendantIds.length : 0,
},
'audit: page trashed',
);
}
}