AcadeDoc/apps/server/src/database/migrations/20260508T100000-create-acadenice-backlink.ts
Corentin 2fc310a2f2 feat(acadenice): add bidirectional backlinks + wikilinks for R3.2
- Migration: acadenice_backlink table (source/target/link_type/excerpt/workspace)
  with 3 indexes and UNIQUE(source,target,type) constraint. Up+down.
- Backend module AcadeniceBacklinksModule:
  BacklinkParserService: walks Tiptap JSON, extracts wikilinks/mentions/databaseView.
  BacklinkIndexerService: idempotent delete-then-insert per page save.
  BacklinkService: permission-aware query (space_members / public visibility).
  BacklinksController: GET /api/acadenice/pages/:pageId/backlinks (JWT auth).
  PageContentUpdatedListener: OnEvent handler for collab saves -> async reindex.
  Tests: 16 Vitest specs (parser/indexer/service/controller).
- PersistenceExtension patch: emits ACADENICE_PAGE_CONTENT_UPDATED_EVENT after
  each collab onStoreDocument (fire-and-forget, no impact on save path).
- CoreModule patch: imports AcadeniceBacklinksModule.
- Frontend WikilinkExtension: Tiptap inline atom node, [[Title]] / [[Title|alias]],
  Suggestion popup (reuses mention pattern + floating-ui), ReactNodeView with
  broken-link state, insertWikilink command.
  Tests: 9 Vitest specs (schema/attrs/commands/HTML parse+render).
- LinkedReferencesPanel: React Query useBacklinks(pageId, staleTime=30s),
  accordion grouped by link_type, excerpt preview, navigate to source page.
  Tests: 7 Vitest specs (loading/error/empty/render/navigate/groups).
- extensions.ts patch: + WikilinkExtension in mainExtensions[].
- full-editor.tsx patch: + LinkedReferencesPanel below editor (Divider + panel).
- i18n: 11 keys added in en-US and fr-FR (backlinks.* + wikilink.*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:51:02 +02:00

84 lines
2.6 KiB
TypeScript

import { Kysely, sql } from 'kysely';
/**
* DocAdenice backlink table (R3.2).
*
* Stores directed edges: source_page_id -> target_page_id.
* Populated by the BacklinkIndexerService each time a page is saved.
*
* link_type discriminates the relationship:
* - 'wikilink' : [[Page Title]] or [[Page Title|alias]] syntax
* - 'mention' : @mention node (entityType = 'page' from Docmost Mention)
* - 'database_embed' : R3.1.c databaseView node that references a page embed
*
* context_excerpt: ~200 chars of surrounding text captured at index time for
* the "Linked references" panel preview. Nullable — populated when text context
* is extractable.
*
* UNIQUE(source_page_id, target_page_id, link_type) ensures the indexer can
* do a simple DELETE-then-INSERT (full reindex per save) without duplicates.
*
* Idempotent: ifNotExists on every CREATE so re-runs never fail.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('acadenice_backlink')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('source_page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('target_page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('link_type', 'varchar(20)', (col) =>
col
.notNull()
.check(
sql`link_type IN ('wikilink', 'mention', 'database_embed')`,
),
)
.addColumn('context_excerpt', 'text')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addUniqueConstraint('acadenice_backlink_source_target_type_unique', [
'source_page_id',
'target_page_id',
'link_type',
])
.execute();
await db.schema
.createIndex('idx_backlink_target')
.ifNotExists()
.on('acadenice_backlink')
.column('target_page_id')
.execute();
await db.schema
.createIndex('idx_backlink_source')
.ifNotExists()
.on('acadenice_backlink')
.column('source_page_id')
.execute();
await db.schema
.createIndex('idx_backlink_workspace')
.ifNotExists()
.on('acadenice_backlink')
.column('workspace_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('acadenice_backlink').ifExists().execute();
}