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
602 lines
24 KiB
TypeScript
602 lines
24 KiB
TypeScript
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',
|
||
);
|
||
}
|
||
}
|