import { ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, MAX_PAGE_DEPTH, OutlineEntry, PageDeleteMode, PageListItemView, PageListQuery, 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 { 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 { 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 { 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 = { 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 { 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 { 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 { 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, parentId: string | null = null, presetSlug?: string, ): Promise { 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 { 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, parentId: string | null = null, presetSlug?: string, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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', ); } }