diff --git a/apps/api/src/links/links.controller.ts b/apps/api/src/links/links.controller.ts index d465427..d596e20 100644 --- a/apps/api/src/links/links.controller.ts +++ b/apps/api/src/links/links.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, Req } from '@nestjs/common'; -import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; +import { BacklinkView, PhantomLinkView, PondGraphView } from '@dorfteich/shared'; import { AuthedRequest } from '../auth/auth.guard'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; @@ -17,6 +17,18 @@ export class LinksController { return this.links.backlinks(request.user!, id); } + /** The pond's readable wikilink graph for the knowledge-graph views + * (issue #111): nodes, resolved edges, and phantom targets — all sliced + * to the caller's read permissions. */ + @Get('ponds/:pondId/links') + @RequiresPondRole('reader', { idParam: 'pondId' }) // every collection is filtered per page + async pondGraph( + @Param('pondId') pondId: string, + @Req() request: AuthedRequest, + ): Promise { + return this.links.pondGraph(request.user!, pondId); + } + /** Referenced-but-missing pages in the pond, with their referrers. */ @Get('ponds/:pondId/phantom-links') @RequiresPondRole('reader', { idParam: 'pondId' }) // referrers are filtered per page diff --git a/apps/api/src/links/links.service.db.test.ts b/apps/api/src/links/links.service.db.test.ts index 153549c..cc77b21 100644 --- a/apps/api/src/links/links.service.db.test.ts +++ b/apps/api/src/links/links.service.db.test.ts @@ -147,3 +147,155 @@ describe.skipIf(!hasTestDb)('LinksService (db, issue #47)', () => { expect((await links.backlinks(owner, target)).map((b) => b.pageId)).toContain(source); }); }); + +describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let links: LinksService; + const suffix = uniqueSuffix(); + let owner: User; + let reader: User; + let pondId: string; + let a: string; + let b: string; + let secret: string; + let trashed: string; + + 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: `g${slug}`, + createdBy: owner.id, + }, + }); + return id; + } + + 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); + + owner = await prisma.user.create({ + data: { + username: `gr-owner-${suffix}`, + email: `gr-owner-${suffix}@example.test`, + displayName: 'Graph Owner', + }, + }); + reader = await prisma.user.create({ + data: { + username: `gr-reader-${suffix}`, + email: `gr-reader-${suffix}@example.test`, + displayName: 'Graph Reader', + }, + }); + const pond = await prisma.pond.create({ + data: { slug: `gr-pond-${suffix}`, name: 'Graph Pond', type: 'SHARED', ownerId: owner.id }, + }); + pondId = pond.id; + await grantOwnerAdmin(prisma, pondId, owner.id); + // Grants before any permission resolution warms the pond's cache. + const secretLabel = await prisma.label.create({ + data: { pondId, name: 'secret', color: '#334455' }, + }); + await prisma.roleGrant.createMany({ + data: [ + { + pondId, + subjectType: 'USER', + subjectId: reader.id, + role: 'READER', + scopeType: 'POND', + effect: 'ALLOW', + createdBy: owner.id, + }, + { + pondId, + subjectType: 'USER', + subjectId: reader.id, + role: 'READER', + scopeType: 'LABEL', + scopeId: secretLabel.id, + effect: 'DENY', + createdBy: owner.id, + }, + ], + }); + + // A → B (resolved), A → S (resolved, S is secret-labeled), A → T + // (resolved, T gets trashed), B → ghost (phantom), S → ghost-secret + // (phantom whose only referrer is hidden from the reader), plus a + // duplicate A → B row under a second slug (rename leftover). + a = await makePage(`gr-a-${suffix}`, 'GA'); + b = await makePage(`gr-b-${suffix}`, 'GB'); + secret = await makePage(`gr-s-${suffix}`, 'GS'); + trashed = await makePage(`gr-t-${suffix}`, 'GT'); + await prisma.pageLabel.create({ data: { pageId: secret, labelId: secretLabel.id } }); + await link(a, `gr-b-${suffix}`, b); + await link(a, `gr-b-old-${suffix}`, b); + await link(a, `gr-s-${suffix}`, secret); + await link(a, `gr-t-${suffix}`, trashed); + await link(b, `ghost-${suffix}`, null); + await link(secret, `ghost-secret-${suffix}`, null); + await prisma.page.update({ + where: { id: trashed }, + data: { deletedAt: new Date(), deletedBy: owner.id }, + }); + }); + + afterAll(async () => { + await prisma.pageLink.deleteMany({ where: { fromPage: { pondId } } }); + await prisma.roleGrant.deleteMany({ where: { pondId } }); + await prisma.pageLabel.deleteMany({ where: { page: { pondId } } }); + await prisma.label.deleteMany({ where: { pondId } }); + await prisma.page.deleteMany({ where: { pondId } }); + await prisma.pond.deleteMany({ where: { id: pondId } }); + await prisma.user.deleteMany({ where: { id: { in: [owner.id, reader.id] } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('returns the full readable graph for the owner, deduplicated and without trash', async () => { + const graph = await links.pondGraph(owner, pondId); + expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB', 'GS']); + // The duplicate A→B row (second slug) collapses into one edge; the edge + // to the trashed page is gone with the node. + expect(graph.edges.sort((x, y) => x.to.localeCompare(y.to))).toEqual( + [ + { from: a, to: b }, + { from: a, to: secret }, + ].sort((x, y) => x.to.localeCompare(y.to)), + ); + expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([ + `ghost-${suffix}`, + `ghost-secret-${suffix}`, + ]); + }); + + it('slices every collection to the reader — no hidden page leaks anywhere', async () => { + const graph = await links.pondGraph(reader, pondId); + // The secret node is gone … + expect(graph.nodes.map((n) => n.title).sort()).toEqual(['GA', 'GB']); + // … the edge into it is dropped even though its source is readable … + expect(graph.edges).toEqual([{ from: a, to: b }]); + // … and the phantom whose only referrer is hidden disappears entirely. + expect(graph.phantoms).toEqual([{ targetSlug: `ghost-${suffix}`, referencedBy: [b] }]); + }); + + it('carries labelIds on nodes for the graph coloring', async () => { + const graph = await links.pondGraph(owner, pondId); + const secretNode = graph.nodes.find((n) => n.id === secret); + expect(secretNode?.labelIds).toHaveLength(1); + }); +}); diff --git a/apps/api/src/links/links.service.ts b/apps/api/src/links/links.service.ts index 7e3ab59..e935ba8 100644 --- a/apps/api/src/links/links.service.ts +++ b/apps/api/src/links/links.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { BacklinkView, PhantomLinkView } from '@dorfteich/shared'; +import { BacklinkView, GraphPhantomView, PhantomLinkView, PondGraphView } from '@dorfteich/shared'; import { User } from '@prisma/client'; import { PermissionService } from '../permissions/permission.service'; @@ -114,4 +114,71 @@ export class LinksService { } return [...grouped.values()]; } + + /** + * The pond's readable wikilink graph (issue #111): every collection is + * sliced to the caller — nodes are the readable pages, an edge survives + * only when BOTH endpoints are readable, and a phantom only with its + * readable referrers (none left → the phantom disappears entirely). So a + * label-scoped reader can never infer a hidden page's existence from the + * graph. Trashed pages and their links are excluded throughout. + */ + async pondGraph(user: User, pondId: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + + const pages = await this.prisma.page.findMany({ + where: { pondId, deletedAt: null }, + select: { id: true, title: true, slug: true, labels: { select: { labelId: true } } }, + orderBy: { title: 'asc' }, + }); + const readable = await this.permissions.filterPages( + user, + pondId, + pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })), + 'read', + ); + const nodes = pages + .filter((page) => readable.has(page.id)) + .map((page) => ({ + id: page.id, + title: page.title, + slug: page.slug, + labelIds: page.labels.map((l) => l.labelId), + })); + + const links = await this.prisma.pageLink.findMany({ + where: { fromPage: { pondId, deletedAt: null } }, + select: { + fromPageId: true, + toPageId: true, + targetSlug: true, + toPage: { select: { deletedAt: true } }, + }, + }); + + // Two rows may resolve to the same target (a rename can leave several + // slugs pointing at one page) — deduplicate per direction. + const edgeKeys = new Set(); + const edges: { from: string; to: string }[] = []; + const phantomMap = new Map(); + for (const link of links) { + if (!readable.has(link.fromPageId)) continue; + if (link.toPageId !== null) { + if (link.toPage?.deletedAt !== null || !readable.has(link.toPageId)) continue; + const key = `${link.fromPageId}→${link.toPageId}`; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ from: link.fromPageId, to: link.toPageId }); + } else { + let entry = phantomMap.get(link.targetSlug); + if (!entry) { + entry = { targetSlug: link.targetSlug, referencedBy: [] }; + phantomMap.set(link.targetSlug, entry); + } + entry.referencedBy.push(link.fromPageId); + } + } + return { nodes, edges, phantoms: [...phantomMap.values()] }; + } } diff --git a/packages/shared/src/links.ts b/packages/shared/src/links.ts index b6635c3..c940268 100644 --- a/packages/shared/src/links.ts +++ b/packages/shared/src/links.ts @@ -18,3 +18,37 @@ export interface PhantomLinkView { targetSlug: string; referencedBy: BacklinkView[]; } + +/** + * The pond's readable wikilink graph (issue #111, `GET /ponds/:id/links`): + * nodes are the caller-readable pages, edges the resolved wikilinks whose + * BOTH endpoints are readable, and phantoms the missing targets with their + * readable referrers — an unreadable page's existence never leaks through + * any of the three collections. Consumed by the knowledge-graph views + * (#112/#113). + */ +export interface GraphNodeView { + id: string; + title: string; + slug: string; + /** For label-based node coloring; resolved via the pond's label tree. */ + labelIds: string[]; +} + +/** One resolved wikilink, by page ids (deduplicated per direction). */ +export interface GraphEdgeView { + from: string; + to: string; +} + +/** A phantom target with the node ids that reference it. */ +export interface GraphPhantomView { + targetSlug: string; + referencedBy: string[]; +} + +export interface PondGraphView { + nodes: GraphNodeView[]; + edges: GraphEdgeView[]; + phantoms: GraphPhantomView[]; +}