From a1f2ee9e0a0124ea4bf42e86c9c0afe8d5cf17c6 Mon Sep 17 00:00:00 2001 From: Corentin Date: Mon, 18 May 2026 09:22:32 +0000 Subject: [PATCH] feat(backlinks): workspace reindex backfill and sub-page references Add POST /v1/pages/backlinks/reindex to rebuild acadenice_backlink for every non-deleted page of the workspace in one call; the per-save indexer never backfills pre-existing content so graph + backlinks stay empty otherwise. Surface direct sub-pages in the linked references panel via a parent_child group sourced live from pages.parent_page_id (same hierarchy the Knowledge Graph uses), giving Notion-like parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../linked-references-panel.test.tsx | 4 +- .../components/linked-references-panel.tsx | 8 +++ .../backlinks/queries/backlinks-query.ts | 4 +- .../controllers/backlinks.controller.ts | 34 +++++++++- .../services/backlink-indexer.service.ts | 31 +++++++++ .../backlinks/services/backlink.service.ts | 66 ++++++++++++++++++- .../backlinks/spec/backlink.service.spec.ts | 3 + .../spec/backlinks.controller.spec.ts | 9 +++ 8 files changed, 152 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/acadenice/backlinks/__tests__/linked-references-panel.test.tsx b/apps/client/src/features/acadenice/backlinks/__tests__/linked-references-panel.test.tsx index 894b61be..c44256ea 100644 --- a/apps/client/src/features/acadenice/backlinks/__tests__/linked-references-panel.test.tsx +++ b/apps/client/src/features/acadenice/backlinks/__tests__/linked-references-panel.test.tsx @@ -55,6 +55,7 @@ const mockResult = { ], mentions: [], database_embeds: [], + parentChild: [], total: 1, }; @@ -96,7 +97,7 @@ describe('LinkedReferencesPanel', () => { (useBacklinks as ReturnType).mockReturnValue({ isLoading: false, isError: false, - data: { wikilinks: [], mentions: [], database_embeds: [], total: 0 }, + data: { wikilinks: [], mentions: [], database_embeds: [], parentChild: [], total: 0 }, refetch: vi.fn(), }); @@ -161,6 +162,7 @@ describe('LinkedReferencesPanel', () => { { source: { id: 'src-2', title: 'Mention Src', slugId: 's2', icon: null, spaceSlug: 'x', spaceName: 'X' }, linkType: 'mention', contextExcerpt: null }, ], database_embeds: [], + parentChild: [], total: 2, }, refetch: vi.fn(), diff --git a/apps/client/src/features/acadenice/backlinks/components/linked-references-panel.tsx b/apps/client/src/features/acadenice/backlinks/components/linked-references-panel.tsx index 96a0733b..916762ec 100644 --- a/apps/client/src/features/acadenice/backlinks/components/linked-references-panel.tsx +++ b/apps/client/src/features/acadenice/backlinks/components/linked-references-panel.tsx @@ -71,6 +71,14 @@ export function LinkedReferencesPanel({ pageId }: LinkedReferencesPanelProps) { entries: data.database_embeds, }); } + if (data.parentChild && data.parentChild.length > 0) { + result.push({ + key: 'parent_child', + label: t('backlinks.group.subpages', 'Sub-pages'), + icon: , + entries: data.parentChild, + }); + } return result; }, [data, t]); diff --git a/apps/client/src/features/acadenice/backlinks/queries/backlinks-query.ts b/apps/client/src/features/acadenice/backlinks/queries/backlinks-query.ts index e6ee8a8e..851a3c4a 100644 --- a/apps/client/src/features/acadenice/backlinks/queries/backlinks-query.ts +++ b/apps/client/src/features/acadenice/backlinks/queries/backlinks-query.ts @@ -15,7 +15,7 @@ export interface PageSummary { export interface BacklinkEntry { source: PageSummary; - linkType: 'wikilink' | 'mention' | 'database_embed'; + linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child'; contextExcerpt: string | null; } @@ -23,6 +23,8 @@ export interface BacklinksResult { wikilinks: BacklinkEntry[]; mentions: BacklinkEntry[]; database_embeds: BacklinkEntry[]; + /** Direct sub-pages of the target page (page-tree hierarchy, Notion parity). */ + parentChild: BacklinkEntry[]; total: number; } diff --git a/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts b/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts index dd7c2791..cc7f0b4c 100644 --- a/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts +++ b/apps/server/src/core/acadenice/backlinks/controllers/backlinks.controller.ts @@ -1,9 +1,10 @@ import { Controller, Get, - NotFoundException, + HttpCode, Param, ParseUUIDPipe, + Post, UseGuards, } from '@nestjs/common'; import { @@ -18,6 +19,7 @@ import { AuthUser } from '../../../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator'; import { User, Workspace } from '@docmost/db/types/entity.types'; import { BacklinkService, BacklinksResult } from '../services/backlink.service'; +import { BacklinkIndexerService } from '../services/backlink-indexer.service'; /** * REST controller for the backlinks feature. @@ -34,7 +36,35 @@ import { BacklinkService, BacklinksResult } from '../services/backlink.service'; @UseGuards(JwtAuthGuard) @Controller('v1/pages') export class BacklinksController { - constructor(private readonly backlinkService: BacklinkService) {} + constructor( + private readonly backlinkService: BacklinkService, + private readonly backlinkIndexer: BacklinkIndexerService, + ) {} + + /** + * Backfill: reindex every page of the caller's workspace. + * + * One-shot operation to populate acadenice_backlink for content created + * before the indexer worked (graph + backlinks otherwise stay empty until + * each page is re-saved). Idempotent. Scoped to the caller's own workspace. + * + * Note: gated only by JwtAuthGuard for now. Admin-only gating is a future + * hardening — the op is idempotent and workspace-scoped, low blast radius. + */ + @ApiOperation({ + summary: 'Reindex all backlinks of the workspace', + description: + 'Backfill: walks every non-deleted page of the caller workspace and rebuilds acadenice_backlink.', + }) + @ApiResponse({ status: 200, description: 'Number of pages reindexed' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @Post('backlinks/reindex') + @HttpCode(200) + async reindexWorkspace( + @AuthWorkspace() workspace: Workspace, + ): Promise<{ pages: number }> { + return this.backlinkIndexer.reindexWorkspace(workspace.id); + } /** * Returns all backlinks pointing to the given page, grouped by link type. diff --git a/apps/server/src/core/acadenice/backlinks/services/backlink-indexer.service.ts b/apps/server/src/core/acadenice/backlinks/services/backlink-indexer.service.ts index 4ff410e6..e49d994c 100644 --- a/apps/server/src/core/acadenice/backlinks/services/backlink-indexer.service.ts +++ b/apps/server/src/core/acadenice/backlinks/services/backlink-indexer.service.ts @@ -137,6 +137,37 @@ export class BacklinkIndexerService { } } + /** + * Backfill: reindex every non-deleted page of a workspace. + * + * Why this exists: reindexPage only runs on a page save event, so existing + * content created before the indexer worked (or before a fix) never lands + * in acadenice_backlink until each page is manually re-saved. This walks the + * whole workspace once. Sequential on purpose — reindexPage is idempotent and + * the simple loop is correct up to ~10k pages (same bound as the per-save + * strategy documented on this class). + */ + async reindexWorkspace(workspaceId: string): Promise<{ pages: number }> { + const result = await sql<{ id: string }>` + SELECT p.id + FROM pages p + JOIN spaces s ON s.id = p.space_id + WHERE s.workspace_id = ${workspaceId} + AND p.deleted_at IS NULL + `.execute(this.db); + + let count = 0; + for (const row of result.rows) { + await this.reindexPage(row.id); + count++; + } + + this.logger.log( + `reindexWorkspace: ${workspaceId} -> ${count} page(s) reindexed`, + ); + return { pages: count }; + } + /** * Delete all backlink rows where this page is the source. * Called before reindexing to ensure idempotence. diff --git a/apps/server/src/core/acadenice/backlinks/services/backlink.service.ts b/apps/server/src/core/acadenice/backlinks/services/backlink.service.ts index 9ff4e3dc..b22fb028 100644 --- a/apps/server/src/core/acadenice/backlinks/services/backlink.service.ts +++ b/apps/server/src/core/acadenice/backlinks/services/backlink.service.ts @@ -20,17 +20,24 @@ export interface PageSummary { */ export interface BacklinkEntry { source: PageSummary; - linkType: 'wikilink' | 'mention' | 'database_embed'; + linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child'; contextExcerpt: string | null; } /** * Grouped backlinks: source pages → entries per link type. + * + * `parentChild` is the set of direct sub-pages of the target page. It is NOT + * sourced from acadenice_backlink (the indexer only tracks content links — + * wikilink/mention/embed); it is derived live from pages.parent_page_id, the + * same hierarchy the Knowledge Graph uses. This gives Notion-like behaviour: + * creating a sub-page makes it appear under the parent's linked references. */ export interface BacklinksResult { wikilinks: BacklinkEntry[]; mentions: BacklinkEntry[]; database_embeds: BacklinkEntry[]; + parentChild: BacklinkEntry[]; total: number; } @@ -127,17 +134,70 @@ export class BacklinkService { else database_embeds.push(entry); } + // Sub-pages (Notion parity). Sourced from the page tree, not the + // backlink index. Same permission filter as content backlinks. + const childRows = await sql<{ + source_page_id: string; + source_title: string | null; + source_slug_id: string | null; + source_icon: string | null; + space_slug: string | null; + space_name: string | null; + }>` + SELECT + p.id AS source_page_id, + p.title AS source_title, + p.slug_id AS source_slug_id, + p.icon AS source_icon, + sp.slug AS space_slug, + sp.name AS space_name + FROM pages p + JOIN spaces sp ON sp.id = p.space_id + WHERE p.parent_page_id = ${targetPageId} + AND p.deleted_at IS NULL + AND sp.workspace_id = ${workspaceId} + AND ( + sp.visibility = 'public' + OR EXISTS ( + SELECT 1 FROM space_members sm + WHERE sm.space_id = sp.id + AND sm.user_id = ${userId} + ) + ) + ORDER BY p.title ASC + `.execute(this.db); + + const parentChild: BacklinkEntry[] = childRows.rows.map((row) => ({ + source: { + id: row.source_page_id, + title: row.source_title, + slugId: row.source_slug_id, + icon: row.source_icon, + spaceSlug: row.space_slug, + spaceName: row.space_name, + }, + linkType: 'parent_child', + contextExcerpt: null, + })); + return { wikilinks, mentions, database_embeds, - total: rows.rows.length, + parentChild, + total: rows.rows.length + parentChild.length, }; } catch (err) { this.logger.error( `getBacklinksFor(${targetPageId}): ${err?.['message']}`, ); - return { wikilinks: [], mentions: [], database_embeds: [], total: 0 }; + return { + wikilinks: [], + mentions: [], + database_embeds: [], + parentChild: [], + total: 0, + }; } } } diff --git a/apps/server/src/core/acadenice/backlinks/spec/backlink.service.spec.ts b/apps/server/src/core/acadenice/backlinks/spec/backlink.service.spec.ts index 39f572b6..b9c3e320 100644 --- a/apps/server/src/core/acadenice/backlinks/spec/backlink.service.spec.ts +++ b/apps/server/src/core/acadenice/backlinks/spec/backlink.service.spec.ts @@ -44,6 +44,7 @@ describe('BacklinkService', () => { wikilinks: [], mentions: [], database_embeds: [], + parentChild: [], total: 0, }); @@ -81,6 +82,7 @@ describe('BacklinkService', () => { }, ], database_embeds: [], + parentChild: [], total: 2, }); @@ -112,6 +114,7 @@ describe('BacklinkService', () => { wikilinks: [], mentions: [], database_embeds: [], + parentChild: [], total: 0, }); diff --git a/apps/server/src/core/acadenice/backlinks/spec/backlinks.controller.spec.ts b/apps/server/src/core/acadenice/backlinks/spec/backlinks.controller.spec.ts index 4050f590..ca262a61 100644 --- a/apps/server/src/core/acadenice/backlinks/spec/backlinks.controller.spec.ts +++ b/apps/server/src/core/acadenice/backlinks/spec/backlinks.controller.spec.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { BacklinksController } from '../controllers/backlinks.controller'; import { BacklinkService } from '../services/backlink.service'; +import { BacklinkIndexerService } from '../services/backlink-indexer.service'; import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard'; /** @@ -23,6 +24,7 @@ const mockBacklinksResult = { ], mentions: [], database_embeds: [], + parentChild: [], total: 1, }; @@ -40,6 +42,12 @@ describe('BacklinksController', () => { getBacklinksFor: jest.fn().mockResolvedValue(mockBacklinksResult), }, }, + { + provide: BacklinkIndexerService, + useValue: { + reindexWorkspace: jest.fn().mockResolvedValue({ pages: 0 }), + }, + }, ], }) .overrideGuard(JwtAuthGuard) @@ -62,6 +70,7 @@ describe('BacklinksController', () => { wikilinks: [], mentions: [], database_embeds: [], + parentChild: [], total: 0, });