From 98e159ab50be6198d3b14c91b0d8ef367fedf693 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sun, 5 Jul 2026 22:25:41 +0200 Subject: [PATCH] Add page CRUD and Yjs state persistence (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma models `pages`/`page_updates`/`page_content_cache` per data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state), PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB operations.md limit or if it doesn't decode into a valid document for the schema), PATCH /pages/:id (title/slug — explicit slug changes validate uniqueness per pond, title-only renames keep the slug), DELETE (soft). Access follows InterimAccessService via the page's pond, same 404-not-403 interim rule as ponds. State saves decode the Yjs update with yjs + y-prosemirror and run it through the #24 shared derivation functions (docToPlainText/ docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache. The Yjs XmlFragment name ("default") and the derivation call are factored so the collab server's persistence hooks (#35) can reuse both. Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test app) to fit base64-encoded page state. Closes #23 --- apps/api/package.json | 7 + .../20260705200714_pages/migration.sql | 59 +++++ apps/api/prisma/schema.prisma | 61 +++++ apps/api/src/app.module.ts | 2 + apps/api/src/main.ts | 3 + apps/api/src/pages/pages.controller.ts | 70 +++++ apps/api/src/pages/pages.e2e.db.test.ts | 248 ++++++++++++++++++ apps/api/src/pages/pages.module.ts | 14 + apps/api/src/pages/pages.service.ts | 208 +++++++++++++++ apps/api/src/pages/yjs-content.ts | 71 +++++ apps/api/src/testing/test-app.ts | 5 +- packages/shared/i18n/de/errors.json | 3 + packages/shared/i18n/en/errors.json | 3 + packages/shared/src/index.ts | 1 + packages/shared/src/pages.ts | 58 ++++ pnpm-lock.yaml | 79 ++++++ 16 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260705200714_pages/migration.sql create mode 100644 apps/api/src/pages/pages.controller.ts create mode 100644 apps/api/src/pages/pages.e2e.db.test.ts create mode 100644 apps/api/src/pages/pages.module.ts create mode 100644 apps/api/src/pages/pages.service.ts create mode 100644 apps/api/src/pages/yjs-content.ts create mode 100644 packages/shared/src/pages.ts diff --git a/apps/api/package.json b/apps/api/package.json index 3383690..f9b14e2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,14 +21,21 @@ "@prisma/client": "^6.3.0", "argon2": "^0.44.0", "cookie-parser": "^1.4.7", + "fractional-indexing": "^4.0.0", "i18next": "^26.3.4", "nestjs-pino": "^4.3.0", "nodemailer": "^9.0.3", "pino": "^9.6.0", "pino-http": "^10.4.0", "prisma": "^6.3.0", + "prosemirror-model": "^1.25.9", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", + "y-prosemirror": "^1.3.7", + "y-protocols": "^1.0.7", + "yjs": "^13.6.31", "zod": "^3.25.76" }, "devDependencies": { diff --git a/apps/api/prisma/migrations/20260705200714_pages/migration.sql b/apps/api/prisma/migrations/20260705200714_pages/migration.sql new file mode 100644 index 0000000..0dff43a --- /dev/null +++ b/apps/api/prisma/migrations/20260705200714_pages/migration.sql @@ -0,0 +1,59 @@ +-- CreateTable +CREATE TABLE "pages" ( + "id" TEXT NOT NULL, + "pond_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "ydoc_state" BYTEA NOT NULL, + "sort_key" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + "deleted_by" TEXT, + + CONSTRAINT "pages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "page_updates" ( + "id" TEXT NOT NULL, + "page_id" TEXT NOT NULL, + "seq" INTEGER NOT NULL, + "update" BYTEA NOT NULL, + + CONSTRAINT "page_updates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "page_content_cache" ( + "page_id" TEXT NOT NULL, + "plain_text" TEXT NOT NULL, + "markdown" TEXT NOT NULL, + "html" TEXT NOT NULL, + "outline" JSONB NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "page_content_cache_pkey" PRIMARY KEY ("page_id") +); + +-- CreateIndex +CREATE INDEX "pages_pond_id_idx" ON "pages"("pond_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "pages_pond_id_slug_key" ON "pages"("pond_id", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "page_updates_page_id_seq_key" ON "page_updates"("page_id", "seq"); + +-- AddForeignKey +ALTER TABLE "pages" ADD CONSTRAINT "pages_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pages" ADD CONSTRAINT "pages_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "page_updates" ADD CONSTRAINT "page_updates_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "page_content_cache" ADD CONSTRAINT "page_content_cache_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3fe21a5..d0bfbf1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { sessions Session[] authTokens AuthToken[] ponds Pond[] + pages Page[] @@map("users") } @@ -74,11 +75,71 @@ model Pond { owner User @relation(fields: [ownerId], references: [id]) usage PondUsage? + pages Page[] @@index([ownerId]) @@map("ponds") } +/// A wiki page (data-model.md §pages). Carries a Yjs document from day one +/// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is +/// the merged state Y.Doc, decoded by the API to derive `PageContentCache` +/// on every save (issue #23). `sortKey` uses fractional indexing so pages +/// can be reordered without rewriting siblings (sidebar reorder is #26). +model Page { + id String @id @default(uuid()) + pondId String @map("pond_id") + title String + slug String + ydocState Bytes @map("ydoc_state") + sortKey String @map("sort_key") + createdBy String @map("created_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + deletedBy String? @map("deleted_by") + + pond Pond @relation(fields: [pondId], references: [id]) + creator User @relation(fields: [createdBy], references: [id]) + updates PageUpdate[] + contentCache PageContentCache? + + @@unique([pondId, slug]) + @@index([pondId]) + @@map("pages") +} + +/// Append log for incremental Yjs updates (data-model.md), compacted +/// periodically. Unused by M2's whole-state REST saves; the collab +/// server's persistence hooks (#35) are the first real writer. +model PageUpdate { + id String @id @default(uuid()) + pageId String @map("page_id") + seq Int + update Bytes + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + + @@unique([pageId, seq]) + @@map("page_updates") +} + +/// Derived plain representation refreshed on every state save (issue #23), +/// built from the Yjs state via the shared editor schema. `outline` is the +/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb. +model PageContentCache { + pageId String @id @map("page_id") + plainText String @map("plain_text") + markdown String + html String + outline Json + updatedAt DateTime @updatedAt @map("updated_at") + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + + @@map("page_content_cache") +} + enum QuotaSubjectType { USER POND diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 365e80b..f8f3393 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { HealthModule } from './health/health.module'; import { MailModule } from './mail/mail.module'; +import { PagesModule } from './pages/pages.module'; import { PondsModule } from './ponds/ponds.module'; import { PrismaModule } from './prisma/prisma.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; @@ -24,6 +25,7 @@ import { UsersModule } from './users/users.module'; SettingsModule, UsersModule, PondsModule, + PagesModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9bb4c3e..2e482c2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -35,6 +35,9 @@ async function bootstrap(): Promise { // real client for rate limiting and audit logs. app.set('trust proxy', 1); app.use(cookieParser()); + // Base64-encoded Yjs page state (max 5 MiB, operations.md) inflates by + // ~4/3; 8 MiB leaves headroom for the JSON envelope around it. + app.useBodyParser('json', { limit: '8mb' }); app.setGlobalPrefix('api/v1'); app.enableShutdownHooks(); diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts new file mode 100644 index 0000000..5aace4d --- /dev/null +++ b/apps/api/src/pages/pages.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Put, + Req, +} from '@nestjs/common'; +import { + CreatePageInput, + PageStateView, + PageView, + SavePageStateInput, + UpdatePageInput, + createPageInputSchema, + savePageStateInputSchema, + updatePageInputSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { PagesService } from './pages.service'; + +/** Page CRUD and Yjs state persistence (issue #23). */ +@Controller() +export class PagesController { + constructor(private readonly pages: PagesService) {} + + @Post('ponds/:pondId/pages') + async create( + @Param('pondId') pondId: string, + @Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput, + @Req() request: AuthedRequest, + ): Promise { + return this.pages.create(request.user!, pondId, input); + } + + @Get('pages/:id') + async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + return this.pages.getState(request.user!, id); + } + + @Put('pages/:id/state') + async saveState( + @Param('id') id: string, + @Body(new ZodValidationPipe(savePageStateInputSchema)) input: SavePageStateInput, + @Req() request: AuthedRequest, + ): Promise { + return this.pages.saveState(request.user!, id, input); + } + + @Patch('pages/:id') + async update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput, + @Req() request: AuthedRequest, + ): Promise { + return this.pages.update(request.user!, id, input); + } + + @Delete('pages/:id') + @HttpCode(204) + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.pages.softDelete(request.user!, id); + } +} diff --git a/apps/api/src/pages/pages.e2e.db.test.ts b/apps/api/src/pages/pages.e2e.db.test.ts new file mode 100644 index 0000000..4383d13 --- /dev/null +++ b/apps/api/src/pages/pages.e2e.db.test.ts @@ -0,0 +1,248 @@ +import { INestApplication } from '@nestjs/common'; +import { editorSchema } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; +import * as Y from 'yjs'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** Encodes a one-paragraph doc with the given text as a base64 Yjs state. */ +function stateWithText(text: string): string { + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment('default'); + const doc = editorSchema.node('doc', null, [ + editorSchema.node('paragraph', null, [editorSchema.text(text)]), + ]); + prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); + const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64'); + ydoc.destroy(); + return state; +} + +describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'seiten voller notizen 1'; + + const owner = { username: `pia-pages-${suffix}`, displayName: `Pia Pages ${suffix}` }; + const outsider = { username: `otto-pages-${suffix}`, displayName: `Otto Outside ${suffix}` }; + let ownerCookie: string; + let outsiderCookie: string; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + async function loginOf(username: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + return sessionCookieOf(res); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: owner.displayName, + password, + locale: 'en', + }); + const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); + ownerCookie = await loginOf(owner.username); + + const outsiderUser = await users.createUser({ + username: outsider.username, + email: `${outsider.username}@example.org`, + displayName: outsider.displayName, + password, + locale: 'en', + }); + await users.markEmailVerified(outsiderUser.id); + outsiderCookie = await loginOf(outsider.username); + + const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); + pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; + }); + + afterAll(async () => { + const users = await prisma.user.findMany({ + where: { username: { contains: suffix } }, + select: { id: true }, + }); + await prisma.page.deleteMany({ + where: { pond: { owner: { username: { contains: suffix } } } }, + }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('creates a page with an empty Yjs state and a title-derived slug', async () => { + const res = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Welcome ${suffix}` }) + .expect(201); + expect(res.body.slug).toBe(`welcome-${suffix}`); + expect(res.body.pondId).toBe(pondId); + + const fetched = await api() + .get(`/api/v1/pages/${res.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(typeof fetched.body.state).toBe('string'); + expect(Buffer.from(fetched.body.state, 'base64').length).toBeGreaterThan(0); + }); + + it('gives duplicate titles deterministic slug suffixes within the same pond', async () => { + const title = `Duplicate ${suffix}`; + const first = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title }) + .expect(201); + const second = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title }) + .expect(201); + expect(first.body.slug).toBe(`duplicate-${suffix}`); + expect(second.body.slug).toBe(`duplicate-${suffix}-2`); + }); + + it('derives plain text and markdown into page_content_cache on state save', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Derivation ${suffix}` }) + .expect(201); + + await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: stateWithText(`hello from ${suffix}`) }) + .expect(200); + + const cache = await prisma.pageContentCache.findUniqueOrThrow({ + where: { pageId: created.body.id }, + }); + expect(cache.plainText).toBe(`hello from ${suffix}`); + expect(cache.markdown).toBe(`hello from ${suffix}`); + expect(cache.html).toBe(`

hello from ${suffix}

`); + }); + + it('rejects state saves beyond the document size limit', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Oversized ${suffix}` }) + .expect(201); + + // Decoded size exceeds the 5 MiB domain limit but its base64 form + // still fits the (much larger) raw HTTP body-size ceiling. + const oversized = Buffer.alloc(5.5 * 1024 * 1024, 1).toString('base64'); + const res = await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: oversized }) + .expect(413); + expect(res.body.code).toBe('page_document_too_large'); + expect(res.body.details.limitBytes).toBe(5 * 1024 * 1024); + }); + + it('rejects state bytes that are not a valid Yjs update', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Garbage ${suffix}` }) + .expect(201); + + const res = await api() + .put(`/api/v1/pages/${created.body.id}/state`) + .set('Cookie', ownerCookie) + .send({ state: Buffer.from('not a yjs update').toString('base64') }) + .expect(400); + expect(res.body.code).toBe('invalid_page_state'); + }); + + it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Original ${suffix}` }) + .expect(201); + + const renamed = await api() + .patch(`/api/v1/pages/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ title: `Renamed ${suffix}` }) + .expect(200); + expect(renamed.body.title).toBe(`Renamed ${suffix}`); + expect(renamed.body.slug).toBe(created.body.slug); + + const other = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Taken ${suffix}` }) + .expect(201); + const conflict = await api() + .patch(`/api/v1/pages/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ slug: other.body.slug }) + .expect(409); + expect(conflict.body.code).toBe('slug_taken'); + + const changed = await api() + .patch(`/api/v1/pages/${created.body.id}`) + .set('Cookie', ownerCookie) + .send({ slug: `custom-slug-${suffix}` }) + .expect(200); + expect(changed.body.slug).toBe(`custom-slug-${suffix}`); + }); + + it('soft-deletes a page', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Trashed ${suffix}` }) + .expect(201); + await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204); + await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(404); + }); + + it('hides pages in foreign ponds (404, not 403)', async () => { + const created = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', ownerCookie) + .send({ title: `Private ${suffix}` }) + .expect(201); + + await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', outsiderCookie).expect(404); + await api() + .patch(`/api/v1/pages/${created.body.id}`) + .set('Cookie', outsiderCookie) + .send({ title: 'hijacked' }) + .expect(404); + await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', outsiderCookie) + .send({ title: 'sneaky' }) + .expect(404); + }); +}); diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts new file mode 100644 index 0000000..7e6544b --- /dev/null +++ b/apps/api/src/pages/pages.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { PondsModule } from '../ponds/ponds.module'; + +import { PagesController } from './pages.controller'; +import { PagesService } from './pages.service'; + +@Module({ + imports: [PondsModule], + controllers: [PagesController], + providers: [PagesService], + exports: [PagesService], +}) +export class PagesModule {} diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts new file mode 100644 index 0000000..c50ddc7 --- /dev/null +++ b/apps/api/src/pages/pages.service.ts @@ -0,0 +1,208 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + PayloadTooLargeException, +} from '@nestjs/common'; +import { + CreatePageInput, + MAX_PAGE_DOCUMENT_BYTES, + PageStateView, + PageView, + SavePageStateInput, + UpdatePageInput, + slugify, +} from '@dorfteich/shared'; +import { Page, Prisma, User } from '@prisma/client'; +import { generateKeyBetween } from 'fractional-indexing'; +import { PinoLogger } from 'nestjs-pino'; + +import { InterimAccessService } from '../ponds/interim-access.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + deriveContent, + DerivedPageContent, + emptyPageState, + InvalidPageStateError, +} 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, + }; +} + +@Injectable() +export class PagesService { + constructor( + private readonly prisma: PrismaService, + private readonly access: InterimAccessService, + private readonly logger: PinoLogger, + ) { + 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; + } + } + + private async findVisiblePage(user: User, id: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { id, deletedAt: null }, + include: { pond: true }, + }); + if (!page) throw new NotFoundException(); + this.access.assertCanSee(user, page.pond); + return page; + } + + private async findModifiablePage(user: User, id: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { id, deletedAt: null }, + include: { pond: true }, + }); + if (!page) throw new NotFoundException(); + this.access.assertCanModify(user, page.pond); + return page; + } + + async create(user: User, pondId: string, input: CreatePageInput): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + this.access.assertCanModify(user, pond); + + const slug = await this.generateUniqueSlugInPond(pond.id, input.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 state = emptyPageState(); + const content = deriveContent(state); + + const page = await this.prisma.page.create({ + data: { + pondId: pond.id, + title: input.title, + slug, + sortKey, + ydocState: state, + createdBy: user.id, + contentCache: { create: contentCacheData(content) }, + }, + }); + this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created'); + return this.viewOf(page); + } + + async getState(user: User, id: string): Promise { + const page = await this.findVisiblePage(user, id); + return this.stateViewOf(page); + } + + async saveState(user: User, id: string, input: SavePageStateInput): Promise { + const page = await this.findModifiablePage(user, id); + const state = new Uint8Array(Buffer.from(input.state, 'base64')); + if (state.length > MAX_PAGE_DOCUMENT_BYTES) { + throw new PayloadTooLargeException({ + code: 'page_document_too_large', + details: { limitBytes: MAX_PAGE_DOCUMENT_BYTES }, + }); + } + + let content: DerivedPageContent; + try { + content = deriveContent(state); + } catch (error) { + if (error instanceof InvalidPageStateError) { + throw new BadRequestException({ code: 'invalid_page_state' }); + } + throw error; + } + + const updated = await this.prisma.page.update({ + where: { id: page.id }, + data: { + ydocState: state, + contentCache: { + upsert: { + create: contentCacheData(content), + update: contentCacheData(content), + }, + }, + }, + }); + this.logger.info({ pageId: id, userId: user.id }, 'audit: page state saved'); + return this.stateViewOf(updated); + } + + async update(user: User, id: string, input: UpdatePageInput): Promise { + const page = await this.findModifiablePage(user, 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 }, + }); + return this.viewOf(updated); + } + + async softDelete(user: User, id: string): Promise { + const page = await this.findModifiablePage(user, 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'); + } +} diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts new file mode 100644 index 0000000..31043a6 --- /dev/null +++ b/apps/api/src/pages/yjs-content.ts @@ -0,0 +1,71 @@ +import { + docToHtml, + docToMarkdown, + docToPlainText, + editorSchema, + extractOutline, + OutlineEntry, +} from '@dorfteich/shared'; +import { Node } from 'prosemirror-model'; +import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; +import * as Y from 'yjs'; + +/** + * The Yjs XmlFragment name the editor binds to (TipTap's collaboration + * extension defaults to "default", #25) — api, web, and collab (#35) must + * all agree on this or Yjs states become unreadable across them. + */ +const FRAGMENT_NAME = 'default'; + +/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ +export class InvalidPageStateError extends Error {} + +function docFromState(state: Uint8Array): Node { + const ydoc = new Y.Doc(); + try { + Y.applyUpdate(ydoc, state); + return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema); + } catch (error) { + throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state'); + } finally { + ydoc.destroy(); + } +} + +/** A fresh Yjs state containing a single empty paragraph. */ +export function emptyPageState(): Uint8Array { + const ydoc = new Y.Doc(); + try { + const fragment = ydoc.getXmlFragment(FRAGMENT_NAME); + const emptyDoc = editorSchema.node('doc', null, [editorSchema.node('paragraph')]); + prosemirrorJSONToYXmlFragment(editorSchema, emptyDoc.toJSON(), fragment); + // Copy into a plain ArrayBuffer-backed view — yjs's own return type is + // the wider `Uint8Array`, which Prisma's Bytes input + // (`Uint8Array`) does not accept directly. + return new Uint8Array(Y.encodeStateAsUpdate(ydoc)); + } finally { + ydoc.destroy(); + } +} + +export interface DerivedPageContent { + plainText: string; + markdown: string; + html: string; + outline: OutlineEntry[]; +} + +/** + * Decodes a page's Yjs state into the derived representations stored in + * `page_content_cache` (issue #23). The collab server (#35) will decode + * the same way and call the same shared derivation functions (#24). + */ +export function deriveContent(state: Uint8Array): DerivedPageContent { + const doc = docFromState(state); + return { + plainText: docToPlainText(doc), + markdown: docToMarkdown(doc), + html: docToHtml(doc), + outline: extractOutline(doc), + }; +} diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index 0e92efc..a666150 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import { AppModule } from '../app.module'; @@ -17,8 +18,10 @@ export async function createTestApp(): Promise { process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent'; const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); - const app = moduleRef.createNestApplication(); + const app = moduleRef.createNestApplication(); app.use(cookieParser()); + // Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default. + app.useBodyParser('json', { limit: '8mb' }); app.setGlobalPrefix('api/v1'); await app.init(); return app; diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index d0adcbd..ee64ce4 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -19,6 +19,9 @@ "cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.", "personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.", "quota_exceeded": "Das Kontingent ist erreicht (Limit: {{limit}}).", + "slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.", + "page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).", + "invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.", "network": "Der Server war nicht erreichbar.", "validation": { "required": "Dieses Feld ist erforderlich.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index af87ad4..55aba23 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -19,6 +19,9 @@ "cannot_revoke_current_session": "Use sign-out to end your current session.", "personal_pond_undeletable": "The personal pond cannot be deleted.", "quota_exceeded": "The quota has been reached (limit: {{limit}}).", + "slug_taken": "This slug is already taken in this pond.", + "page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).", + "invalid_page_state": "The submitted page content is invalid.", "network": "The server could not be reached.", "validation": { "required": "This field is required.", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 20ea98e..e6c8797 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,5 +4,6 @@ export * from './editor-schema'; export * from './env'; export * from './health'; export * from './i18n-tools'; +export * from './pages'; export * from './ponds'; export * from './quotas'; diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts new file mode 100644 index 0000000..89a39da --- /dev/null +++ b/packages/shared/src/pages.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +/** + * Page schemas and views shared between api and web (issue #23). A page + * carries a Yjs document from day one (ADR 0003); in M2 its state is + * saved wholesale over REST as a base64 string. + */ + +export const pageTitleSchema = z + .string() + .trim() + .min(1, 'validation.required') + .max(200, 'validation.tooLong'); + +export const pageSlugSchema = z + .string() + .trim() + .min(1, 'validation.required') + .max(60, 'validation.tooLong'); + +export const createPageInputSchema = z.object({ + title: pageTitleSchema, +}); +export type CreatePageInput = z.infer; + +export const updatePageInputSchema = z + .object({ + title: pageTitleSchema, + slug: pageSlugSchema, + }) + .partial(); +export type UpdatePageInput = z.infer; + +export const savePageStateInputSchema = z.object({ + /** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */ + state: z.string().min(1, 'validation.required'), +}); +export type SavePageStateInput = z.infer; + +/** Max Yjs document size (operations.md §Limits) — a fixed operational + * ceiling, not a per-pond/user quota. */ +export const MAX_PAGE_DOCUMENT_BYTES = 5 * 1024 * 1024; + +export interface PageView { + id: string; + pondId: string; + title: string; + slug: string; + sortKey: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +/** What `GET /pages/:id` returns: page meta plus its base64 Yjs state. */ +export interface PageStateView extends PageView { + state: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec627b6..488e156 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: cookie-parser: specifier: ^1.4.7 version: 1.4.7 + fractional-indexing: + specifier: ^4.0.0 + version: 4.0.0 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) @@ -68,12 +71,30 @@ importers: prisma: specifier: ^6.3.0 version: 6.19.3(typescript@5.9.3) + prosemirror-model: + specifier: ^1.25.9 + version: 1.25.9 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-view: + specifier: ^1.42.0 + version: 1.42.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 rxjs: specifier: ^7.8.0 version: 7.8.2 + y-prosemirror: + specifier: ^1.3.7 + version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.31) + yjs: + specifier: ^13.6.31 + version: 13.6.31 zod: specifier: ^3.25.76 version: 3.25.76 @@ -2305,6 +2326,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@4.0.0: + resolution: {integrity: sha512-Nr2P1Yyaj2sy1Qdt/wI3GcByxrUdbSKg5+cGvw8f18hT2SkQt6V72iOjBpk7IO3UkzBsdlGRs2a4z6dLrJprgw==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2467,6 +2492,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterare@1.2.1: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} @@ -2531,6 +2559,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3638,6 +3671,22 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3645,6 +3694,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5681,6 +5734,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@4.0.0: {} + fresh@2.0.0: {} fs-extra@10.1.0: @@ -5821,6 +5876,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + iterare@1.2.1: {} jest-worker@27.5.1: @@ -5872,6 +5929,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -6962,10 +7023,28 @@ snapshots: wrappy@1.0.2: {} + y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.0 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + yallist@3.1.1: {} yargs-parser@21.1.1: {} + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {}