diff --git a/apps/api/prisma/migrations/20260709104519_page_links/migration.sql b/apps/api/prisma/migrations/20260709104519_page_links/migration.sql new file mode 100644 index 0000000..c7aa4b9 --- /dev/null +++ b/apps/api/prisma/migrations/20260709104519_page_links/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "page_links" ( + "id" TEXT NOT NULL, + "from_page_id" TEXT NOT NULL, + "to_page_id" TEXT, + "target_slug" TEXT NOT NULL, + + CONSTRAINT "page_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "page_links_to_page_id_idx" ON "page_links"("to_page_id"); + +-- CreateIndex +CREATE INDEX "page_links_target_slug_idx" ON "page_links"("target_slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "page_links_from_page_id_target_slug_key" ON "page_links"("from_page_id", "target_slug"); + +-- AddForeignKey +ALTER TABLE "page_links" ADD CONSTRAINT "page_links_from_page_id_fkey" FOREIGN KEY ("from_page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "page_links" ADD CONSTRAINT "page_links_to_page_id_fkey" FOREIGN KEY ("to_page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c463446..173335e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -110,6 +110,8 @@ model Page { versions PageVersion[] pendingContributors PagePendingContributor[] labels PageLabel[] + outgoingLinks PageLink[] @relation("outgoingLinks") + incomingLinks PageLink[] @relation("incomingLinks") @@unique([pondId, slug]) @@index([pondId]) @@ -234,6 +236,27 @@ model Label { @@map("labels") } +/// Wikilink index maintained on every content change (data-model.md §page_links, +/// issue #47). One row per (source page, distinct target slug). `toPageId` is +/// the resolved target within the same pond, or null for a "phantom" link whose +/// target does not exist yet; creating/renaming a page to that slug resolves the +/// row. Backlinks query by `toPageId`. Collab (the content writer) maintains the +/// outgoing rows over raw SQL; it does not use Prisma. +model PageLink { + id String @id @default(uuid()) + fromPageId String @map("from_page_id") + toPageId String? @map("to_page_id") + targetSlug String @map("target_slug") + + fromPage Page @relation("outgoingLinks", fields: [fromPageId], references: [id], onDelete: Cascade) + toPage Page? @relation("incomingLinks", fields: [toPageId], references: [id], onDelete: SetNull) + + @@unique([fromPageId, targetSlug]) + @@index([toPageId]) + @@index([targetSlug]) + @@map("page_links") +} + /// Assignment of a label to a page (data-model.md §labels). Cascades on both /// sides: purging a page or deleting a label removes the assignment. model PageLabel { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9d8b98a..dcbb715 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { ConfigModule } from './config/config.module'; import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; import { LabelsModule } from './labels/labels.module'; +import { LinksModule } from './links/links.module'; import { MailModule } from './mail/mail.module'; import { PagesModule } from './pages/pages.module'; import { PondsModule } from './ponds/ponds.module'; @@ -36,6 +37,7 @@ import { VersionsModule } from './versions/versions.module'; CompactionModule, VersionsModule, LabelsModule, + LinksModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/links/links.controller.ts b/apps/api/src/links/links.controller.ts new file mode 100644 index 0000000..fc43704 --- /dev/null +++ b/apps/api/src/links/links.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param, Req } from '@nestjs/common'; +import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { LinksService } from './links.service'; + +/** Wikilink index reads (issue #47): backlinks and a pond's phantom links. */ +@Controller() +export class LinksController { + constructor(private readonly links: LinksService) {} + + /** Pages that link to this page (permission-filtered). */ + @Get('pages/:id/backlinks') + async backlinks(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + return this.links.backlinks(request.user!, id); + } + + /** Referenced-but-missing pages in the pond, with their referrers. */ + @Get('ponds/:pondId/phantom-links') + async phantomLinks( + @Param('pondId') pondId: string, + @Req() request: AuthedRequest, + ): Promise { + return this.links.phantomLinks(request.user!, pondId); + } +} diff --git a/apps/api/src/links/links.module.ts b/apps/api/src/links/links.module.ts new file mode 100644 index 0000000..c6af4d1 --- /dev/null +++ b/apps/api/src/links/links.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { PondsModule } from '../ponds/ponds.module'; + +import { LinksController } from './links.controller'; +import { LinksService } from './links.service'; + +@Module({ + imports: [PondsModule], + controllers: [LinksController], + providers: [LinksService], + exports: [LinksService], +}) +export class LinksModule {} diff --git a/apps/api/src/links/links.service.db.test.ts b/apps/api/src/links/links.service.db.test.ts new file mode 100644 index 0000000..36e9dd7 --- /dev/null +++ b/apps/api/src/links/links.service.db.test.ts @@ -0,0 +1,142 @@ +import { randomUUID } from 'node:crypto'; + +import { INestApplication, NotFoundException } from '@nestjs/common'; +import { PrismaClient, User } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; + +import { PagesService } from '../pages/pages.service'; +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { LinksService } from './links.service'; + +describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let links: LinksService; + let pages: PagesService; + const suffix = uniqueSuffix(); + let owner: User; + let outsider: User; + let pondId: string; + const pageIds: string[] = []; + + /** Creates a page with an explicit slug (bypassing slug generation). */ + async function makePage(slug: string, title = slug): Promise { + const id = randomUUID(); + await prisma.page.create({ + data: { + id, + pondId, + title, + slug, + ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())), + sortKey: `a${pageIds.length}`, + createdBy: owner.id, + }, + }); + pageIds.push(id); + return id; + } + + /** Simulates what the collab persistence writes for one outgoing link. */ + async function link(fromId: string, targetSlug: string, toId: string | null): Promise { + await prisma.pageLink.create({ + data: { fromPageId: fromId, toPageId: toId, targetSlug }, + }); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + links = app.get(LinksService); + pages = app.get(PagesService); + + owner = await prisma.user.create({ + data: { + username: `lnk-owner-${suffix}`, + email: `lnk-owner-${suffix}@example.test`, + displayName: 'Link Owner', + }, + }); + outsider = await prisma.user.create({ + data: { + username: `lnk-out-${suffix}`, + email: `lnk-out-${suffix}@example.test`, + displayName: 'Link Outsider', + }, + }); + const pond = await prisma.pond.create({ + data: { slug: `lnk-pond-${suffix}`, name: 'Link Pond', type: 'PERSONAL', ownerId: owner.id }, + }); + pondId = pond.id; + // Headroom for the pages the create test makes. + await prisma.quotaOverride.create({ + data: { subjectType: 'USER', subjectId: owner.id, quotaKey: 'additional_ponds', value: 100 }, + }); + }); + + afterAll(async () => { + await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } }); + await prisma.page.deleteMany({ where: { pondId } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: owner.id } }); + await prisma.pond.deleteMany({ where: { id: pondId } }); + await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('lists backlinks to a page', async () => { + const target = await makePage(`target-${suffix}`); + const source = await makePage(`source-${suffix}`, 'Source Page'); + await link(source, `target-${suffix}`, target); + + const backlinks = await links.backlinks(owner, target); + expect(backlinks).toEqual([{ pageId: source, title: 'Source Page', slug: `source-${suffix}` }]); + }); + + it('hides backlinks from users who cannot see the pond', async () => { + const target = await makePage(`hidden-${suffix}`); + await expect(links.backlinks(outsider, target)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('aggregates phantom links by target slug', async () => { + const a = await makePage(`ph-a-${suffix}`, 'A'); + const b = await makePage(`ph-b-${suffix}`, 'B'); + await link(a, `ghost-${suffix}`, null); + await link(b, `ghost-${suffix}`, null); + + const phantoms = await links.phantomLinks(owner, pondId); + const ghost = phantoms.find((p) => p.targetSlug === `ghost-${suffix}`); + expect(ghost?.referencedBy.map((r) => r.title).sort()).toEqual(['A', 'B']); + }); + + it('resolves phantom links when a page with the target slug is created', async () => { + const source = await makePage(`res-src-${suffix}`, 'Res Source'); + await link(source, 'brand-new-page', null); + + const created = await pages.create(owner, pondId, { title: 'Brand New Page' }); + pageIds.push(created.id); + expect(created.slug).toBe('brand-new-page'); + + // The phantom now points at the created page — it is a backlink, not phantom. + const backlinks = await links.backlinks(owner, created.id); + expect(backlinks.map((b) => b.pageId)).toContain(source); + const phantoms = await links.phantomLinks(owner, pondId); + expect(phantoms.some((p) => p.targetSlug === 'brand-new-page')).toBe(false); + }); + + it('re-points phantom links on rename, and id-based backlinks survive a later target rename', async () => { + const source = await makePage(`rn-src-${suffix}`, 'Rename Source'); + await link(source, 'wanted-slug', null); + const target = await makePage(`rn-tgt-${suffix}`, 'Rename Target'); + + // Rename the target to the wanted slug → the phantom resolves. + await pages.update(owner, target, { slug: 'wanted-slug' }); + expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source); + + // Rename the target's slug again: the backlink stores the id, so it survives. + await pages.update(owner, target, { slug: 'wanted-slug-renamed' }); + expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source); + }); +}); diff --git a/apps/api/src/links/links.service.ts b/apps/api/src/links/links.service.ts new file mode 100644 index 0000000..783b9b3 --- /dev/null +++ b/apps/api/src/links/links.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; +import { User } from '@prisma/client'; + +import { InterimAccessService } from '../ponds/interim-access.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Reads over the wikilink index (`page_links`, issue #47). The index is written + * by the collab server on every content change; here it is queried for a page's + * backlinks and a pond's phantom (missing-target) links. Wikilinks resolve only + * within a pond, so every backlink lives in the same pond as its target — + * seeing the pond (InterimAccessService) is therefore the read permission. + */ +@Injectable() +export class LinksService { + constructor( + private readonly prisma: PrismaService, + private readonly access: InterimAccessService, + ) {} + + /** Pages linking to `pageId`, filtered to what the user may read (issue #47). */ + async backlinks(user: User, pageId: string): Promise { + const page = await this.prisma.page.findFirst({ + where: { id: pageId, deletedAt: null }, + include: { pond: true }, + }); + if (!page) throw new NotFoundException(); + this.access.assertCanSee(user, page.pond); + + const links = await this.prisma.pageLink.findMany({ + where: { toPageId: pageId, fromPage: { deletedAt: null } }, + include: { fromPage: { select: { id: true, title: true, slug: true } } }, + orderBy: { fromPage: { title: 'asc' } }, + }); + const seen = new Set(); + const backlinks: BacklinkView[] = []; + for (const link of links) { + if (seen.has(link.fromPage.id)) continue; + seen.add(link.fromPage.id); + backlinks.push({ + pageId: link.fromPage.id, + title: link.fromPage.title, + slug: link.fromPage.slug, + }); + } + return backlinks; + } + + /** Referenced-but-missing targets in a pond, with the pages referencing them. */ + async phantomLinks(user: User, pondId: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + this.access.assertCanSee(user, pond); + + const rows = await this.prisma.pageLink.findMany({ + where: { toPageId: null, fromPage: { pondId, deletedAt: null } }, + include: { fromPage: { select: { id: true, title: true, slug: true } } }, + orderBy: [{ targetSlug: 'asc' }, { fromPage: { title: 'asc' } }], + }); + + const grouped = new Map(); + for (const row of rows) { + let entry = grouped.get(row.targetSlug); + if (!entry) { + entry = { targetSlug: row.targetSlug, referencedBy: [] }; + grouped.set(row.targetSlug, entry); + } + entry.referencedBy.push({ + pageId: row.fromPage.id, + title: row.fromPage.title, + slug: row.fromPage.slug, + }); + } + return [...grouped.values()]; + } +} diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index e10ee45..06d1569 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -155,10 +155,26 @@ export class PagesService { contentCache: { create: contentCacheData(content) }, }, }); + // A new page may satisfy phantom wikilinks that referenced its slug (#47). + await this.resolvePhantomLinks(pond.id, slug, page.id); this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created'); return this.viewOf(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.findVisiblePage(user, id); return this.stateViewOf(page); @@ -229,6 +245,8 @@ export class PagesService { 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); return this.viewOf(updated); } diff --git a/apps/collab/src/persistence.db.test.ts b/apps/collab/src/persistence.db.test.ts index a60bea0..6c76da5 100644 --- a/apps/collab/src/persistence.db.test.ts +++ b/apps/collab/src/persistence.db.test.ts @@ -21,6 +21,19 @@ function makeDoc(text: string): Y.Doc { return doc; } +/** A Y.Doc whose paragraph contains a `[[slug]]` wikilink per slug (issue #47). */ +function makeDocWithWikilinks(...slugs: string[]): Y.Doc { + const doc = new Y.Doc(); + const inline = [ + editorSchema.text('See '), + ...slugs.map((slug) => editorSchema.node('wikilink', { targetSlug: slug, displayText: null })), + ]; + const paragraph = editorSchema.node('paragraph', null, inline); + const pmDoc = editorSchema.node('doc', null, [paragraph]); + prosemirrorJSONToYXmlFragment(editorSchema, pmDoc.toJSON(), doc.getXmlFragment('default')); + return doc; +} + /** * A minimal valid Yjs state with no document content. Seeding pages with an * empty state (rather than an empty paragraph) keeps these SQL round-trip tests @@ -149,4 +162,33 @@ describe.skipIf(!url)('PostgresPagePersistence (DB-backed)', () => { const loaded = await persistence.loadInto(missingId, new Y.Doc()); expect(loaded).toBe(false); }); + + it('maintains the page_links index on store — resolved and phantom (issue #47)', async () => { + const target = await createPage(); + const targetSlug = `wl-target-${target.slice(0, 8)}`; + await pool.query('UPDATE pages SET slug = $2 WHERE id = $1', [target, targetSlug]); + const source = await createPage(); + const persistence = new PostgresPagePersistence(pool); + + // The doc links an existing page and a missing one. + const doc = makeDocWithWikilinks(targetSlug, 'ghost-slug'); + const result = await persistence.store(source, doc); + expect(result.outcome).toBe('stored'); + + const rows = await pool.query<{ target_slug: string; to_page_id: string | null }>( + 'SELECT target_slug, to_page_id FROM page_links WHERE from_page_id = $1 ORDER BY target_slug', + [source], + ); + const bySlug = new Map(rows.rows.map((r) => [r.target_slug, r.to_page_id])); + expect(bySlug.get(targetSlug)).toBe(target); // resolved + expect(bySlug.has('ghost-slug')).toBe(true); + expect(bySlug.get('ghost-slug')).toBeNull(); // phantom + + // Re-storing without the ghost link removes its row (index is rewritten). + await persistence.store(source, makeDocWithWikilinks(targetSlug)); + const after = await pool.query('SELECT target_slug FROM page_links WHERE from_page_id = $1', [ + source, + ]); + expect(after.rows.map((r) => r.target_slug)).toEqual([targetSlug]); + }); }); diff --git a/apps/collab/src/persistence.ts b/apps/collab/src/persistence.ts index 4ec723d..159e09a 100644 --- a/apps/collab/src/persistence.ts +++ b/apps/collab/src/persistence.ts @@ -165,6 +165,21 @@ export class PostgresPagePersistence implements PagePersistence { ); } + // Rewrite this page's outgoing wikilink index (issue #47): replace all its + // rows with one per distinct target slug, resolved to a page in the same + // pond (null `to_page_id` = phantom, target does not exist yet). + await client.query('DELETE FROM page_links WHERE from_page_id = $1', [pageId]); + if (derived.wikilinkSlugs.length > 0) { + await client.query( + `INSERT INTO page_links (id, from_page_id, to_page_id, target_slug) + SELECT gen_random_uuid(), $1, target.id, s.link_slug + FROM unnest($2::text[]) AS s(link_slug) + LEFT JOIN pages AS target + ON target.pond_id = $3 AND target.slug = s.link_slug AND target.deleted_at IS NULL`, + [pageId, derived.wikilinkSlugs, pondId], + ); + } + await client.query('COMMIT'); this.lastStoredVector.set(pageId, nextVector); return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged }; diff --git a/apps/collab/src/yjs-content.ts b/apps/collab/src/yjs-content.ts index 96dc9d7..d19c353 100644 --- a/apps/collab/src/yjs-content.ts +++ b/apps/collab/src/yjs-content.ts @@ -4,6 +4,7 @@ import { docToPlainText, editorSchema, extractOutline, + extractWikilinkSlugs, type OutlineEntry, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; @@ -40,6 +41,9 @@ export interface DerivedPageContent { * `Attachment.pageId` pointed at the page that embeds the file (issue #31), * mirroring the api's REST save path. */ imageFileIds: string[]; + /** Distinct target slugs of every `[[wikilink]]`, for the `page_links` + * index (issue #47). */ + wikilinkSlugs: string[]; } function imageFileIdsOf(doc: Node): string[] { @@ -65,5 +69,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent { html: docToHtml(doc), outline: extractOutline(doc), imageFileIds: imageFileIdsOf(doc), + wikilinkSlugs: extractWikilinkSlugs(doc), }; } diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index 9fcae94..01a549a 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -3,3 +3,4 @@ export * from './markdown'; export * from './html'; export * from './plain-text'; export * from './outline'; +export * from './wikilinks'; diff --git a/packages/shared/src/editor-schema/wikilinks.test.ts b/packages/shared/src/editor-schema/wikilinks.test.ts new file mode 100644 index 0000000..ee8e17f --- /dev/null +++ b/packages/shared/src/editor-schema/wikilinks.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { markdownToDoc } from './markdown'; +import { extractWikilinkSlugs } from './wikilinks'; + +describe('extractWikilinkSlugs (issue #47)', () => { + it('collects distinct target slugs in order of first appearance', () => { + const doc = markdownToDoc('Link to [[alpha]] and [[beta|Bee]] and again [[alpha]].'); + expect(extractWikilinkSlugs(doc)).toEqual(['alpha', 'beta']); + }); + + it('returns an empty list when there are no wikilinks', () => { + expect(extractWikilinkSlugs(markdownToDoc('Just plain text, no links.'))).toEqual([]); + }); +}); diff --git a/packages/shared/src/editor-schema/wikilinks.ts b/packages/shared/src/editor-schema/wikilinks.ts new file mode 100644 index 0000000..b296079 --- /dev/null +++ b/packages/shared/src/editor-schema/wikilinks.ts @@ -0,0 +1,22 @@ +import { Node } from 'prosemirror-model'; + +/** + * The distinct target slugs of every `[[wikilink]]` in a document (issue #47). + * Used server-side to maintain the `page_links` index on every content change, + * so backlinks and phantom (missing-target) links can be queried. Order of + * first appearance is preserved; duplicates are collapsed. + */ +export function extractWikilinkSlugs(doc: Node): string[] { + const slugs: string[] = []; + const seen = new Set(); + doc.descendants((node) => { + if (node.type.name === 'wikilink') { + const slug = node.attrs.targetSlug as string; + if (slug && !seen.has(slug)) { + seen.add(slug); + slugs.push(slug); + } + } + }); + return slugs; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4356d13..2a63c10 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './files'; export * from './health'; export * from './i18n-tools'; export * from './labels'; +export * from './links'; export * from './pages'; export * from './ponds'; export * from './quotas'; diff --git a/packages/shared/src/links.ts b/packages/shared/src/links.ts new file mode 100644 index 0000000..e00bd3e --- /dev/null +++ b/packages/shared/src/links.ts @@ -0,0 +1,18 @@ +/** + * Wikilink index views shared between api and web (issue #47/#48). The index + * (`page_links`) is maintained on every content change; these are the read + * shapes for the backlinks panel and the pond's missing-pages view. + */ + +/** A page that links to the page being viewed (its backlink). */ +export interface BacklinkView { + pageId: string; + title: string; + slug: string; +} + +/** A referenced-but-missing target and the pages that link to it. */ +export interface PhantomLinkView { + targetSlug: string; + referencedBy: BacklinkView[]; +}