Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
router.replace(`?${params.toString()}`);
}, [fetchDoc, router, searchParams]);

const handleReorder = useCallback(async (docId: string, newSortOrder: number, siblings: Doc[]) => {

Check warning on line 153 in apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx

View workflow job for this annotation

GitHub Actions / Lint, Type Check, Test, Build

'siblings' is defined but never used
setTree((prev) =>
prev.map((doc) => (doc.id === docId ? { ...doc, sort_order: newSortOrder } : doc))
);
Expand Down Expand Up @@ -479,6 +479,8 @@
value={content}
contentFormat={contentFormat}
editable={true} // TODO: pass per-doc permission when RBAC is introduced (e.g. canEdit ?? true)
currentDocId={selectedDoc.id}
onNavigate={handleSelectDoc}
onChange={setContent}
onContentFormatChange={setContentFormat}
labels={{
Expand Down
171 changes: 171 additions & 0 deletions apps/web/src/app/api/docs/preview/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { extractEmbedIds } from './route';

// ---------------------------------------------------------------------------
// Pure helper — extractEmbedIds
// ---------------------------------------------------------------------------

describe('extractEmbedIds', () => {
it('returns empty array for null input', () => {
expect(extractEmbedIds(null)).toEqual([]);
});

it('returns empty array for undefined input', () => {
expect(extractEmbedIds(undefined)).toEqual([]);
});

it('returns empty array when no embed nodes in HTML', () => {
expect(extractEmbedIds('<p>Hello world</p>')).toEqual([]);
});

it('extracts a single doc ID from a page-embed div', () => {
const html = '<div data-page-embed data-doc-id="abc-123" data-title="My Doc" data-slug="my-doc"></div>';
expect(extractEmbedIds(html)).toEqual(['abc-123']);
});

it('extracts multiple doc IDs from multiple embed nodes', () => {
const html = [
'<div data-page-embed data-doc-id="id-1" data-slug="doc-1"></div>',
'<p>Some text</p>',
'<div data-page-embed data-doc-id="id-2" data-slug="doc-2"></div>',
].join('\n');
expect(extractEmbedIds(html)).toEqual(['id-1', 'id-2']);
});

it('ignores elements without data-doc-id', () => {
const html = '<div data-page-embed data-slug="no-id"></div>';
expect(extractEmbedIds(html)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// Route handler — GET /api/docs/preview
// ---------------------------------------------------------------------------

const { createSupabaseServerClient, createSupabaseAdminClient, getAuthContext } = vi.hoisted(() => ({
createSupabaseServerClient: vi.fn(),
createSupabaseAdminClient: vi.fn(),
getAuthContext: vi.fn(),
}));

const getDocPreviewMock = vi.fn();

vi.mock('@/lib/supabase/server', () => ({ createSupabaseServerClient }));
vi.mock('@/lib/supabase/admin', () => ({ createSupabaseAdminClient }));
vi.mock('@/lib/auth-helpers', () => ({ getAuthContext }));
vi.mock('@/services/docs', () => ({
DocsService: class {
getDocPreview = getDocPreviewMock;
},
}));

// Supabase client stub for collectTransitiveEmbeds BFS (no embeds in target docs)
const fromMock = vi.fn().mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn().mockResolvedValue({ data: [] }),
});
const mockDbClient = { from: fromMock };

import { GET } from './route';

const mockAuth = {
id: 'team-member-1',
org_id: 'org-1',
project_id: 'project-1',
project_name: 'Test',
type: 'human' as const,
rateLimitExceeded: false,
};

describe('GET /api/docs/preview', () => {
beforeEach(() => {
createSupabaseServerClient.mockReset();
createSupabaseAdminClient.mockReset();
getAuthContext.mockReset();
getDocPreviewMock.mockReset();
fromMock.mockClear();

createSupabaseServerClient.mockResolvedValue(mockDbClient);
createSupabaseAdminClient.mockReturnValue(mockDbClient);
getAuthContext.mockResolvedValue(mockAuth);
});

it('returns 401 when not authenticated', async () => {
getAuthContext.mockResolvedValue(null);
const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc'));
expect(res.status).toBe(401);
});

it('returns 429 when rate limit exceeded', async () => {
getAuthContext.mockResolvedValue({
...mockAuth,
rateLimitExceeded: true,
rateLimitRemaining: 0,
rateLimitResetAt: 9999,
});
const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc'));
expect(res.status).toBe(429);
});

it('returns 400 when q param is missing', async () => {
const res = await GET(new Request('http://localhost/api/docs/preview'));
expect(res.status).toBe(400);
});

it('returns 404 when document is not found', async () => {
getDocPreviewMock.mockResolvedValue(null);
const res = await GET(new Request('http://localhost/api/docs/preview?q=nonexistent'));
expect(res.status).toBe(404);
});

it('returns preview fields with empty embedChain when doc has no embeds', async () => {
getDocPreviewMock.mockResolvedValue({
id: 'doc-abc',
title: 'My Doc',
icon: '📄',
slug: 'my-doc',
content: '<p>No embeds here</p>',
});

const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc'));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toMatchObject({
id: 'doc-abc',
title: 'My Doc',
icon: '📄',
slug: 'my-doc',
embedChain: [],
});
});

it('accepts UUID as q param and passes it to service', async () => {
const uuid = '12345678-1234-1234-1234-123456789abc';
getDocPreviewMock.mockResolvedValue({
id: uuid,
title: 'UUID Doc',
icon: null,
slug: 'uuid-doc',
content: null,
});

const res = await GET(new Request(`http://localhost/api/docs/preview?q=${uuid}`));
expect(res.status).toBe(200);
expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', uuid);
});

it('accepts slug as q param and passes it to service', async () => {
getDocPreviewMock.mockResolvedValue({
id: 'doc-xyz',
title: 'Slug Doc',
icon: null,
slug: 'my-slug',
content: null,
});

const res = await GET(new Request('http://localhost/api/docs/preview?q=my-slug'));
expect(res.status).toBe(200);
expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', 'my-slug');
});
});
104 changes: 104 additions & 0 deletions apps/web/src/app/api/docs/preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { createSupabaseAdminClient } from '@/lib/supabase/admin';
import { DocsService } from '@/services/docs';
import { getAuthContext } from '@/lib/auth-helpers';
import { apiSuccess, ApiErrors } from '@/lib/api-response';
import { handleApiError } from '@/lib/api-error';
import type { SupabaseClient } from '@supabase/supabase-js';

/**
* Extract all doc IDs referenced by page-embed nodes from an HTML string.
* Exported for unit testing.
*/
export function extractEmbedIds(html: string | null | undefined): string[] {
if (!html) return [];
const ids: string[] = [];
const regex = /data-doc-id="([^"]+)"/g;
let m: RegExpExecArray | null;
while ((m = regex.exec(html)) !== null) {
if (m[1]) ids.push(m[1]);
}
return ids;
}

/**
* BFS over the embed graph starting from `startDocId`, collecting all
* transitively-embedded doc IDs (up to `maxDepth` hops).
* Used to detect indirect circular embeds (A→B→A).
*/
async function collectTransitiveEmbeds(
supabase: SupabaseClient,
projectId: string,
startDocId: string,
maxDepth = 5,
): Promise<string[]> {
const visited = new Set<string>([startDocId]);
const chain: string[] = [];
let frontier = [startDocId];

for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
const { data } = await supabase
.from('docs')
.select('id, content')
.eq('project_id', projectId)
.in('id', frontier);

if (!data?.length) break;

const nextFrontier: string[] = [];
for (const doc of data) {
for (const id of extractEmbedIds(doc.content)) {
if (!visited.has(id)) {
visited.add(id);
chain.push(id);
nextFrontier.push(id);
}
}
}
frontier = nextFrontier;
}

return chain;
}

/**
* GET /api/docs/preview?q=<slug-or-uuid>
*
* Returns the minimal preview fields needed by page-embed blocks:
* { id, title, icon, slug, embedChain }
*
* `embedChain` lists all doc IDs transitively embedded by the target doc.
* Clients use this to detect indirect circular embeds (A→B→A).
*
* Accepts both UUID and slug via the `q` parameter.
*/
export async function GET(request: Request) {
try {
const supabase = await createSupabaseServerClient();
const me = await getAuthContext(supabase, request);
if (!me) return ApiErrors.unauthorized();
if (me.rateLimitExceeded)
return ApiErrors.tooManyRequests(me.rateLimitRemaining, me.rateLimitResetAt);
const dbClient = me.type === 'agent' ? createSupabaseAdminClient() : supabase;

const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
if (!q) return ApiErrors.badRequest('q is required');

const service = new DocsService(dbClient);
const doc = await service.getDocPreview(me.project_id, q);
if (!doc) return ApiErrors.notFound('Document not found');

const embedChain = await collectTransitiveEmbeds(dbClient, me.project_id, doc.id);

return apiSuccess({
id: doc.id,
title: doc.title,
icon: doc.icon ?? null,
slug: doc.slug,
embedChain,
});
} catch (err: unknown) {
return handleApiError(err);
}
}
8 changes: 8 additions & 0 deletions apps/web/src/components/docs/doc-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import TableHeader from '@tiptap/extension-table-header';
import Placeholder from '@tiptap/extension-placeholder';
import { CalloutNode } from './extensions/callout-node';
import { SlashCommandExtension } from './extensions/slash-command';
import { PageEmbedExtension } from './extensions/page-embed-node';
import { markdownToHtml, htmlToMarkdown } from './lib/content-converter';

type ContentFormat = 'markdown' | 'html';
Expand All @@ -20,13 +21,19 @@ export function DocEditor({
value,
contentFormat,
editable = true,
currentDocId,
onNavigate,
onChange,
onContentFormatChange,
labels,
}: {
value: string;
contentFormat: ContentFormat;
editable?: boolean;
/** ID of the currently open document — prevents self-embed in page-embed blocks. */
currentDocId?: string;
/** Called when user clicks an embedded page link. */
onNavigate?: (slug: string) => void;
onChange: (value: string) => void;
onContentFormatChange: (format: ContentFormat) => void;
labels: {
Expand Down Expand Up @@ -60,6 +67,7 @@ export function DocEditor({
Placeholder.configure({ placeholder: labels.placeholder }),
CalloutNode,
SlashCommandExtension,
PageEmbedExtension.configure({ currentDocId, onNavigate }),
],
editable,
content: contentFormat === 'markdown' ? markdownToHtml(value) : value,
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/components/docs/extensions/page-embed-node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Unit tests for page-embed-node.tsx
*
* Focuses on pure exported helpers — isCircularEmbed.
* The Tiptap extension itself and the React node view require a browser
* environment (jsdom + full editor setup) and are covered by smoke testing.
*/
import { describe, expect, it } from 'vitest';
import { isCircularEmbed } from './page-embed-node';

describe('isCircularEmbed — direct self-embed (A embeds A)', () => {
it('returns true when docId matches currentDocId', () => {
expect(isCircularEmbed('doc-abc', 'doc-abc')).toBe(true);
});

it('returns false when docId differs from currentDocId', () => {
expect(isCircularEmbed('doc-abc', 'doc-xyz')).toBe(false);
});

it('returns false when docId is null (no doc selected yet)', () => {
expect(isCircularEmbed(null, 'doc-abc')).toBe(false);
});

it('returns false when docId is undefined', () => {
expect(isCircularEmbed(undefined, 'doc-abc')).toBe(false);
});

it('returns false when currentDocId is undefined (editor not bound to a doc)', () => {
expect(isCircularEmbed('doc-abc', undefined)).toBe(false);
});

it('returns false when both are undefined', () => {
expect(isCircularEmbed(undefined, undefined)).toBe(false);
});

it('returns false when both are null/undefined mix', () => {
expect(isCircularEmbed(null, undefined)).toBe(false);
});
});

describe('isCircularEmbed — indirect cycle (A→B→A via embedChain)', () => {
it('returns true when currentDocId appears in embedChain (A embeds B, B embeds A)', () => {
// currentDoc = 'doc-a', target = 'doc-b', doc-b embeds doc-a → cycle
expect(isCircularEmbed('doc-b', 'doc-a', ['doc-a', 'doc-c'])).toBe(true);
});

it('returns true when currentDocId appears deep in embedChain (A→B→C→A)', () => {
expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d', 'doc-a'])).toBe(true);
});

it('returns false when embedChain does not contain currentDocId', () => {
expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d'])).toBe(false);
});

it('returns false when embedChain is empty', () => {
expect(isCircularEmbed('doc-b', 'doc-a', [])).toBe(false);
});

it('defaults to empty embedChain when not provided — no cycle', () => {
expect(isCircularEmbed('doc-b', 'doc-a')).toBe(false);
});

it('returns false when docId is null even if embedChain contains currentDocId', () => {
// No target doc selected — cannot form a cycle
expect(isCircularEmbed(null, 'doc-a', ['doc-a'])).toBe(false);
});

it('returns false when currentDocId is undefined even if embedChain is non-empty', () => {
expect(isCircularEmbed('doc-b', undefined, ['doc-x', 'doc-y'])).toBe(false);
});

it('direct self-embed takes priority regardless of embedChain', () => {
// docId === currentDocId is caught before checking embedChain
expect(isCircularEmbed('doc-a', 'doc-a', [])).toBe(true);
});
});
Loading
Loading