dorfteich/apps/api/src/pages/pages.service.ts
Claude Opus 4.8 546e8279ac
All checks were successful
CD / Build and push images (push) Successful in 3m19s
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Import .docx and .odt documents as new pages (#63)
Uploading a Word/OpenOffice document to POST /ponds/:id/import enqueues a
conversion job (the #62 queue) that produces a new page in the pond; the
client polls GET /jobs/:id for the created resultPageId.

Pipeline (ImportService, ADR 0009): pandoc-server is stateless and hands
back a document's media no other way, so we convert in two passes —
docx/odt → html with embed-resources inlines every image as a data: URI,
then html → gfm produces clean structural Markdown with those data URIs
still inline. Embedded images are stored as pond files (with quota
accounting) and their references rewritten to file ids on the Markdown
text before parsing (the editor parser only admits png/jpeg/gif/webp data
URIs); an image whose bytes the upload pipeline rejects is dropped, not
fatal. The title comes from a leading top-level heading (removed from the
body) else the file name. The page is created from the resulting Yjs state.

The shared conversion worker routes import-kind jobs to the pipeline via a
token (breaking a module cycle), so import inherits the queue's locking,
retry, and restart-survival. Media stored during a failed attempt is rolled
back; a pond that runs out of storage fails the job with quota_exceeded.

- schema: ConversionJob gains pond_id / source_name / result_page_id
  (migration 20260710041215_import_pages_conversion); ConversionJobView
  gains resultPageId.
- PagesService.createWithState / yjs-content docToState build a page from a
  prepared document; FilesService.linkAttachmentsToPage links import media.
- fixtures/import/: representative .docx/.odt corpus (headings, lists,
  nested lists, tables, images, links, bold/italic) with expected-Markdown
  snapshots; scripts/gen-import-fixtures.mjs regenerates them.
- tests: import.service.db.test.ts drives the full pipeline with a fake
  converter (CI); import.fixtures.test.ts runs the real two-pass conversion
  over the corpus and a 50-page timing check against a reachable sidecar.
- i18n: import_unsupported_format (de+en). Limits documented (25 MiB input,
  60 s per pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 07:34:43 +02:00

364 lines
14 KiB
TypeScript

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<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;
}
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. */
async list(user: User, pondId: string): 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 },
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<PageView> {
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<ArrayBuffer>,
): Promise<Page> {
return this.insertPage(user, pondId, title, state);
}
private async insertPage(
user: User,
pondId: string,
title: string,
state: Uint8Array<ArrayBuffer>,
): Promise<Page> {
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<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). 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<PageView> {
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<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]! } })),
);
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<void> {
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');
}
}