Skip to content

Commit 3542af5

Browse files
feat: full-text search across pages (#30) (#53)
* feat: full-text search across pages (#30) Co-authored-by: Ona <no-reply@ona.com> * chore: re-trigger PR review Co-authored-by: Ona <no-reply@ona.com> * fix(search): add membership guard to search_pages RPC and add API tests - Add auth.uid() membership check in search_pages before executing query, preventing cross-workspace search by unauthenticated workspace members - Add integration tests for GET /api/search covering all code paths: 503 (not configured), 400 (missing params, query too long), 401 (unauth), 500 (RPC error), 200 (results and empty results) Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent a7b9241 commit 3542af5

7 files changed

Lines changed: 693 additions & 3 deletions

File tree

.agents/architecture.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ workspaces
4646
├── parent_id → pages.id (nullable, enables nesting)
4747
├── content: jsonb (Lexical editor state — NOT a separate blocks table)
4848
├── position: integer (ordering among siblings)
49-
└── created_by → profiles.id
49+
├── created_by → profiles.id
50+
└── search_vector: tsvector (generated, title weight A + content text weight B, GIN indexed)
5051
5152
Sign-up flow (atomic, via DB trigger):
5253
1. auth.users row created by Supabase Auth
@@ -70,7 +71,7 @@ Sign-up flow (atomic, via DB trigger):
7071
| Session management | Next.js 16 proxy (not middleware) | `src/proxy.ts` with `updateSession` — Next.js 16 convention replacing middleware |
7172
| Floating UI | `@floating-ui/react` | Positioning for slash command menu, floating toolbar, link editor (same as Lexical playground) |
7273
| Image storage | Supabase Storage | Bucket for uploaded images, public URL stored in ImageNode |
73-
| Full-text search | PostgreSQL `tsvector` + `tsquery` | Generated column or trigger on page title + extracted content text |
74+
| Full-text search | PostgreSQL `tsvector` + `tsquery` | Generated column on pages combining title (weight A) + extracted content text (weight B), GIN index, `search_pages` RPC |
7475

7576
## Lexical Editor — Implementation Plan
7677

@@ -158,7 +159,8 @@ src/
158159
│ │ ├── page.tsx # /[workspaceSlug] — workspace home (page list or empty state)
159160
│ │ └── [pageId]/page.tsx # /[workspaceSlug]/[pageId] — page with title + editor placeholder
160161
│ └── api/
161-
│ └── health/route.ts # Health check endpoint (DB connectivity)
162+
│ ├── health/route.ts # Health check endpoint (DB connectivity)
163+
│ └── search/route.ts # Full-text search (GET ?q=&workspace_id=) → calls search_pages RPC
162164
├── components/
163165
│ ├── auth/
164166
│ │ ├── oauth-buttons.tsx # GitHub + Google buttons (disabled, "coming soon" tooltip)
@@ -169,6 +171,7 @@ src/
169171
│ │ ├── sidebar-context.tsx # React context for sidebar open/close state + ⌘+\ shortcut
170172
│ │ ├── workspace-switcher.tsx # Dropdown listing all workspaces, create workspace trigger
171173
│ │ ├── create-workspace-dialog.tsx # Dialog for creating a new workspace
174+
│ │ ├── page-search.tsx # Full-text search input + results dropdown (debounced, 300ms)
172175
│ │ ├── page-tree.tsx # Hierarchical page tree with CRUD, drag-and-drop, nest/unnest
173176
│ │ └── user-menu.tsx # User dropdown with settings link + sign-out
174177
│ ├── editor/ # Lexical block editor

.agents/conventions.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ await supabase.auth.signUp({
404404
});
405405
```
406406

407+
<<<<<<< HEAD
407408
## Lexical Editor Plugins
408409

409410
Editor plugins live in `src/components/editor/`. Each plugin is a separate file with
@@ -517,6 +518,27 @@ toast.error("Something went wrong", { duration: 8000 });
517518
Per design spec: toasts use `rounded-sm`, position bottom-right. Only show toasts for
518519
errors, async completions, and destructive actions with undo — not for routine actions.
519520

521+
## Supabase RPC (database functions)
522+
523+
When a query requires features not available through the Supabase query builder
524+
(e.g., `ts_rank`, `ts_headline`, complex joins with computed columns), create a
525+
PostgreSQL function and call it via `supabase.rpc()`.
526+
527+
```typescript
528+
// Calling an RPC function from a route handler
529+
const { data, error } = await supabase.rpc("search_pages", {
530+
query: "search term",
531+
ws_id: workspaceId,
532+
result_limit: 20,
533+
});
534+
```
535+
536+
Rules:
537+
- Define the function in a migration with `security definer` and `set search_path = ''`.
538+
- Use `stable` for read-only functions, `volatile` for mutations.
539+
- Keep the function focused — one purpose per function.
540+
- Return a `table(...)` type for multi-row results so the client gets typed arrays.
541+
520542
## This file evolves
521543

522544
When you discover a new pattern that should be replicated, or an anti-pattern that

src/app/api/search/route.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
// Mock next/headers cookies before importing the route
5+
vi.mock("next/headers", () => ({
6+
cookies: vi.fn().mockResolvedValue({
7+
getAll: () => [],
8+
set: vi.fn(),
9+
}),
10+
}));
11+
12+
// Mock the Supabase server client
13+
vi.mock("@/lib/supabase/server", () => ({
14+
createClient: vi.fn(),
15+
}));
16+
17+
import { GET } from "./route";
18+
import { createClient } from "@/lib/supabase/server";
19+
20+
const mockedCreateClient = vi.mocked(createClient);
21+
22+
function makeRequest(params: Record<string, string> = {}): NextRequest {
23+
const url = new URL("http://localhost:3000/api/search");
24+
for (const [key, value] of Object.entries(params)) {
25+
url.searchParams.set(key, value);
26+
}
27+
return new NextRequest(url);
28+
}
29+
30+
beforeEach(() => {
31+
vi.restoreAllMocks();
32+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
33+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "test-key";
34+
});
35+
36+
describe("GET /api/search", () => {
37+
it("returns 503 when Supabase is not configured", async () => {
38+
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
39+
40+
const response = await GET(makeRequest({ q: "test", workspace_id: "ws-1" }) );
41+
const body = await response.json();
42+
43+
expect(response.status).toBe(503);
44+
expect(body.error).toBe("Supabase not configured");
45+
});
46+
47+
it("returns 400 when q parameter is missing", async () => {
48+
const response = await GET(makeRequest({ workspace_id: "ws-1" }) );
49+
const body = await response.json();
50+
51+
expect(response.status).toBe(400);
52+
expect(body.error).toContain("Missing required parameters");
53+
});
54+
55+
it("returns 400 when workspace_id parameter is missing", async () => {
56+
const response = await GET(makeRequest({ q: "test" }) );
57+
const body = await response.json();
58+
59+
expect(response.status).toBe(400);
60+
expect(body.error).toContain("Missing required parameters");
61+
});
62+
63+
it("returns 400 when query exceeds 200 characters", async () => {
64+
const longQuery = "a".repeat(201);
65+
const response = await GET(
66+
makeRequest({ q: longQuery, workspace_id: "ws-1" }) as never,
67+
);
68+
const body = await response.json();
69+
70+
expect(response.status).toBe(400);
71+
expect(body.error).toContain("Query too long");
72+
});
73+
74+
it("returns 401 when user is not authenticated", async () => {
75+
const mockGetUser = vi.fn().mockResolvedValue({ data: { user: null } });
76+
mockedCreateClient.mockResolvedValue({
77+
auth: { getUser: mockGetUser },
78+
} as unknown as Awaited<ReturnType<typeof createClient>>);
79+
80+
const response = await GET(
81+
makeRequest({ q: "test", workspace_id: "ws-1" }) as never,
82+
);
83+
const body = await response.json();
84+
85+
expect(response.status).toBe(401);
86+
expect(body.error).toBe("Unauthorized");
87+
});
88+
89+
it("returns 500 when RPC call fails", async () => {
90+
const mockGetUser = vi
91+
.fn()
92+
.mockResolvedValue({ data: { user: { id: "user-1" } } });
93+
const mockRpc = vi
94+
.fn()
95+
.mockResolvedValue({ data: null, error: { message: "RPC error" } });
96+
mockedCreateClient.mockResolvedValue({
97+
auth: { getUser: mockGetUser },
98+
rpc: mockRpc,
99+
} as unknown as Awaited<ReturnType<typeof createClient>>);
100+
101+
const response = await GET(
102+
makeRequest({ q: "test", workspace_id: "ws-1" }) as never,
103+
);
104+
const body = await response.json();
105+
106+
expect(response.status).toBe(500);
107+
expect(body.error).toBe("Search failed");
108+
expect(mockRpc).toHaveBeenCalledWith("search_pages", {
109+
query: "test",
110+
ws_id: "ws-1",
111+
result_limit: 20,
112+
});
113+
});
114+
115+
it("returns 200 with results on success", async () => {
116+
const mockResults = [
117+
{
118+
id: "page-1",
119+
workspace_id: "ws-1",
120+
parent_id: null,
121+
title: "Test Page",
122+
icon: "📄",
123+
snippet: "<<test>> content here",
124+
rank: 0.5,
125+
},
126+
];
127+
const mockGetUser = vi
128+
.fn()
129+
.mockResolvedValue({ data: { user: { id: "user-1" } } });
130+
const mockRpc = vi
131+
.fn()
132+
.mockResolvedValue({ data: mockResults, error: null });
133+
mockedCreateClient.mockResolvedValue({
134+
auth: { getUser: mockGetUser },
135+
rpc: mockRpc,
136+
} as unknown as Awaited<ReturnType<typeof createClient>>);
137+
138+
const response = await GET(
139+
makeRequest({ q: "test", workspace_id: "ws-1" }) as never,
140+
);
141+
const body = await response.json();
142+
143+
expect(response.status).toBe(200);
144+
expect(body.results).toEqual(mockResults);
145+
});
146+
147+
it("returns 200 with empty array when no results", async () => {
148+
const mockGetUser = vi
149+
.fn()
150+
.mockResolvedValue({ data: { user: { id: "user-1" } } });
151+
const mockRpc = vi.fn().mockResolvedValue({ data: null, error: null });
152+
mockedCreateClient.mockResolvedValue({
153+
auth: { getUser: mockGetUser },
154+
rpc: mockRpc,
155+
} as unknown as Awaited<ReturnType<typeof createClient>>);
156+
157+
const response = await GET(
158+
makeRequest({ q: "test", workspace_id: "ws-1" }) as never,
159+
);
160+
const body = await response.json();
161+
162+
expect(response.status).toBe(200);
163+
expect(body.results).toEqual([]);
164+
});
165+
});

src/app/api/search/route.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
4+
export async function GET(request: NextRequest) {
5+
if (
6+
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
7+
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
8+
) {
9+
return NextResponse.json(
10+
{ error: "Supabase not configured" },
11+
{ status: 503 }
12+
);
13+
}
14+
15+
const { searchParams } = request.nextUrl;
16+
const query = searchParams.get("q")?.trim();
17+
const workspaceId = searchParams.get("workspace_id");
18+
19+
if (!query || !workspaceId) {
20+
return NextResponse.json(
21+
{ error: "Missing required parameters: q, workspace_id" },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
if (query.length > 200) {
27+
return NextResponse.json(
28+
{ error: "Query too long (max 200 characters)" },
29+
{ status: 400 }
30+
);
31+
}
32+
33+
try {
34+
const supabase = await createClient();
35+
36+
const { data: user } = await supabase.auth.getUser();
37+
if (!user.user) {
38+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
39+
}
40+
41+
const { data, error } = await supabase.rpc("search_pages", {
42+
query,
43+
ws_id: workspaceId,
44+
result_limit: 20,
45+
});
46+
47+
if (error) {
48+
return NextResponse.json(
49+
{ error: "Search failed" },
50+
{ status: 500 }
51+
);
52+
}
53+
54+
return NextResponse.json({ results: data ?? [] });
55+
} catch {
56+
return NextResponse.json(
57+
{ error: "Internal server error" },
58+
{ status: 500 }
59+
);
60+
}
61+
}

src/components/sidebar/app-sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/components/ui/sheet";
1111
import { useSidebar } from "@/components/sidebar/sidebar-context";
1212
import { WorkspaceSwitcher } from "@/components/sidebar/workspace-switcher";
13+
import { PageSearch } from "@/components/sidebar/page-search";
1314
import { PageTree } from "@/components/sidebar/page-tree";
1415
import { UserMenu } from "@/components/sidebar/user-menu";
1516

@@ -28,6 +29,8 @@ function SidebarContent({
2829
<div className="flex h-full flex-col gap-2 p-2">
2930
<WorkspaceSwitcher userId={userId} />
3031
<Separator className="bg-white/[0.06]" />
32+
<PageSearch />
33+
<Separator className="bg-white/[0.06]" />
3134
<PageTree userId={userId} />
3235
<Separator className="bg-white/[0.06]" />
3336
<UserMenu displayName={displayName} email={email} />

0 commit comments

Comments
 (0)