import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, PageListItemView, PageStateView, PageView, RepositionPageInput, SidebarSortMode, UpdatePageInput, pondSettingsSchema, slugify, } 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 { 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, ) { this.logger.setContext(PagesService.name); } viewOf(page: Page): PageView { return { id: page.id, pondId: page.pondId, 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; } 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. */ async list(user: User, pondId: string): 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 }, 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), labelIds: page.labels.map((l) => l.labelId), })); } async create(user: User, pondId: string, input: CreatePageInput): Promise { const page = await this.insertPage(user, pondId, input.title, emptyPageState()); 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, ): Promise { return this.insertPage(user, pondId, title, state); } private async insertPage( user: User, pondId: string, title: string, state: Uint8Array, ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); const slug = await this.generateUniqueSlugInPond(pond.id, title); const last = await this.prisma.page.findFirst({ where: { pondId: pond.id }, orderBy: { sortKey: 'desc' }, select: { sortKey: true }, }); const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const content = deriveContent(state); const page = await this.prisma.page.create({ data: { pondId: pond.id, title, slug, sortKey, ydocState: state, createdBy: user.id, contentCache: { create: contentCacheData(content) }, }, }); // A new page may satisfy phantom wikilinks that referenced its slug (#47). await this.resolvePhantomLinks(pond.id, slug, page.id); // Index the page so a title-only match is findable immediately (#49). await this.search.indexPage(page.id); this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created'); return page; } /** * Point phantom `page_links` (unresolved, `to_page_id` null) whose * `target_slug` matches `slug` within the pond at `pageId` (issue #47). * Called when a page is created or renamed to a slug that pages already link * to. Backlinks store the target's id, so once resolved a later target rename * keeps them connected. */ private async resolvePhantomLinks(pondId: string, slug: string, pageId: string): Promise { 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). 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. 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' }); } 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 } }); return this.viewOf(updated); } return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId); } /** * 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, ): 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]! } })), ); 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 ?? '' }; } async softDelete(user: User, id: string): Promise { const page = await this.findLivePage(id); await this.prisma.page.update({ where: { id: page.id }, data: { deletedAt: new Date(), deletedBy: user.id }, }); this.logger.info({ pageId: id, userId: user.id }, 'audit: page trashed'); } }