diff --git a/app/api/version/route.ts b/app/api/version/route.ts index 2db0fd9..25a18c2 100644 --- a/app/api/version/route.ts +++ b/app/api/version/route.ts @@ -101,39 +101,84 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Document snapshot exceeds maximum allowed size' }, { status: 413 }) } - // 1. Verify user role: only owners and editors can create versions - const { data: doc } = await supabaseClient - .from('documents') - .select('owner_id') + // 1. Verify user role: only owners and editors can create versions. + // Pages are the primary entity; legacy documents fall back for + // unmapped ids (rollback path). The page lookup must be trusted + // (service-role, RLS-bypassing): an RLS-filtered null here would also + // mean "no access", and legacy documents share the id space with pages + // (P1 backfill kept twin records), so an authorized legacy-document + // user could otherwise slip past page authorization. + const { data: page } = await supabaseAdmin + .from('pages') + .select('id') .eq('id', documentId) - .single() + .maybeSingle() - const isOwner = doc && doc.owner_id === user.id + let isOwner = false let isEditor = false - if (!isOwner) { - const { data: member } = await supabaseClient - .from('document_members') - .select('role') - .eq('document_id', documentId) - .eq('user_id', user.id) - .single() - - isEditor = !!(member && member.role === 'editor') + if (page) { + // Page path: authorize through the caller-scoped client so RLS + // still gates what the caller can see. + const { data: pageRow } = await supabaseClient + .from('pages') + .select('owner_id') + .eq('id', documentId) + .maybeSingle() + + isOwner = !!(pageRow && pageRow.owner_id === user.id) + + if (!isOwner) { + const { data: member } = await supabaseClient + .from('page_members') + .select('role') + .eq('page_id', documentId) + .eq('user_id', user.id) + .single() + + isEditor = !!(member && member.role === 'editor') + } + } else { + const { data: doc } = await supabaseClient + .from('documents') + .select('owner_id') + .eq('id', documentId) + .maybeSingle() + + isOwner = !!(doc && doc.owner_id === user.id) + + if (!isOwner && doc) { + const { data: member } = await supabaseClient + .from('document_members') + .select('role') + .eq('document_id', documentId) + .eq('user_id', user.id) + .single() + + isEditor = !!(member && member.role === 'editor') + } } if (!isOwner && !isEditor) { return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }) } - // 2. Create the document_versions record + // 2. Create the document_versions record (page_id for pages) const { data: version, error: dbError } = await supabaseClient .from('document_versions') - .insert({ - document_id: documentId, - version_name: versionName, - created_by: user.id, - }) + .insert( + page + ? { + page_id: documentId, + version_name: versionName, + created_by: user.id, + } + : { + document_id: documentId, + version_name: versionName, + created_by: user.id, + } + ) .select() .single() diff --git a/app/doc/[id]/page.tsx b/app/doc/[id]/page.tsx index 852aca5..3206ed5 100644 --- a/app/doc/[id]/page.tsx +++ b/app/doc/[id]/page.tsx @@ -1,100 +1,10 @@ -'use client' +import { redirect } from 'next/navigation' -import { use, useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' -import { supabase } from '@/lib/supabase' -import { fetchDocumentDetails } from '@/services/db' -import GlobalLoader from '@/components/global-loader' -import EditorWorkspace from '@/components/editor-workspace' -import { toast } from 'sonner' - -export default function DocumentPage({ +export default async function DocumentPage({ params: paramsPromise, }: { params: Promise<{ id: string }> }) { - const params = use(paramsPromise) - const router = useRouter() - const [user, setUser] = useState(null) - const [token, setToken] = useState(null) - const [documentTitle, setDocumentTitle] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const loadDocumentAndSession = async () => { - try { - // 1. Get current session and token - const { data: { session } } = await supabase.auth.getSession() - const { error: userError } = await supabase.auth.getUser() - - // If there is an auth error that is NOT just a missing session, it means the token expired or is invalid - if (userError && userError.name !== 'AuthSessionMissingError') { - console.error('Session error (token expired):', userError) - toast.error('Session expired. Please log in again.') - router.push('/login') - return - } - - if (session) { - setUser(session.user) - setToken(session.access_token) - } else { - // Check if document is public - try { - const doc = await fetchDocumentDetails(params.id) - if (doc && doc.is_public) { - // Mock anonymous user - const randomId = Math.random().toString(36).substring(7) - setUser({ - id: `anon-${randomId}`, - email: 'anonymous@public', - full_name: 'Anonymous Viewer' - }) - setToken('anonymous') - } else { - router.push('/login') - return - } - } catch { - router.push('/login') - return - } - } - - // 2. Fetch document details using wrapper service - const doc = await fetchDocumentDetails(params.id) - setDocumentTitle(doc.title) - } catch (err: unknown) { - - console.error('Error loading document page:', err) - toast.error('Document not found or access denied') - router.push('/') - } finally { - setLoading(false) - } - } - - loadDocumentAndSession() - }, [params.id, router]) - - if (loading) { - return - } - - if (!user || !token || !documentTitle) { - return null - } - - return ( - - ) + const params = await paramsPromise + redirect(`/page/${params.id}`) } diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index f6d1ca7..926c4db 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -3,8 +3,8 @@ import { use, useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { supabase } from '@/lib/supabase' -import { DocumentInvitation } from '@/types' -import { fetchInvitationDetails, acceptInvitation, declineInvitation } from '@/services/db' +import { PageInvitation } from '@/types' +import { fetchPageInvitationDetails, acceptPageInvitation, declinePageInvitation } from '@/services/graph' import GlobalLoader from '@/components/global-loader' import { toast } from 'sonner' @@ -16,7 +16,7 @@ export default function InvitePage ({ const params = use(paramsPromise) const router = useRouter() const [user, setUser] = useState(null) - const [invite, setInvite] = useState(null) + const [invite, setInvite] = useState(null) const [loading, setLoading] = useState(true) const [processing, setProcessing] = useState(false) @@ -36,10 +36,16 @@ export default function InvitePage ({ setUser(session.user) // 2. Fetch invitation details - const invitation = await fetchInvitationDetails(params.token) + const invitation = await fetchPageInvitationDetails(params.token) if (invitation.status === 'accepted') { - router.push(`/doc/${invitation.document_id}`) + router.push(`/page/${invitation.page_id}`) + return + } + + if (invitation.status !== 'pending') { + toast.error('This invitation is no longer available') + router.push('/') return } @@ -62,8 +68,8 @@ export default function InvitePage ({ setProcessing(true) try { - await acceptInvitation(invite, user.id) - router.push(`/doc/${invite.document_id}`) + await acceptPageInvitation(invite, user.id) + router.push(`/page/${invite.page_id}`) } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) toast.error(`Failed to accept: ${message}`) @@ -78,7 +84,7 @@ export default function InvitePage ({ setProcessing(true) try { - await declineInvitation(invite.id) + await declinePageInvitation(invite.id) router.push('/') } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) @@ -99,18 +105,18 @@ export default function InvitePage ({

- Document Invitation + Page Invitation

- You have been invited to join a collaborative document workspace + You have been invited to join a collaborative page workspace

- Document Title + Page Title

- {invite.documents?.title || 'Untitled'} + {invite.pages?.title || 'Untitled'}

diff --git a/app/page/[id]/page.tsx b/app/page/[id]/page.tsx new file mode 100644 index 0000000..6673d80 --- /dev/null +++ b/app/page/[id]/page.tsx @@ -0,0 +1,108 @@ +'use client' + +import { use, useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { supabase } from '@/lib/supabase' +import { fetchPageDetails } from '@/services/graph' +import GlobalLoader from '@/components/global-loader' +import EditorWorkspace from '@/components/editor-workspace' +import { toast } from 'sonner' + +export default function PageRoute({ + params: paramsPromise, +}: { + params: Promise<{ id: string }> +}) { + const params = use(paramsPromise) + const router = useRouter() + const [user, setUser] = useState(null) + const [token, setToken] = useState(null) + const [pageTitle, setPageTitle] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + const loadPageAndSession = async () => { + try { + // 1. Get current session and token + const { data: { session } } = await supabase.auth.getSession() + const { error: userError } = await supabase.auth.getUser() + + // If there is an auth error that is NOT just a missing session, it means the token expired or is invalid + if (userError && userError.name !== 'AuthSessionMissingError') { + console.error('Session error (token expired):', userError) + toast.error('Session expired. Please log in again.') + router.push('/login') + return + } + + // 2. Fetch page details once and reuse the result for both the + // public-page validation and the page title state. + let page: Awaited> | null = null + if (session) { + if (cancelled) return + setUser(session.user) + setToken(session.access_token) + page = await fetchPageDetails(params.id) + } else { + // Check if page is public + try { + page = await fetchPageDetails(params.id) + if (page && page.is_public) { + if (cancelled) return + setUser({ + id: `anon-${crypto.randomUUID()}`, + email: 'anonymous@public', + full_name: 'Anonymous Viewer' + }) + setToken('anonymous') + } else { + router.push('/login') + return + } + } catch { + router.push('/login') + return + } + } + + if (cancelled) return + setPageTitle(page.title) + } catch (err: unknown) { + console.error('Error loading page:', err) + toast.error('Page not found or access denied') + router.push('/') + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + loadPageAndSession() + return () => { + cancelled = true + } + }, [params.id, router]) + + if (loading) { + return + } + + if (!user || !token || pageTitle === null) { + return null + } + + return ( + + ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1ed3f6e..8afc84b 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -5,12 +5,12 @@ import { useRouter } from 'next/navigation' import { supabase } from '@/lib/supabase' import GlobalLoader from '@/components/global-loader' import SettingsClient from '@/components/settings-client' -import { fetchOwnedDocumentsWithMembers } from '@/services/db' +import { fetchOwnedPagesWithMembers } from '@/services/graph' export default function SettingsPage() { const router = useRouter() const [user, setUser] = useState(null) - const [documents, setDocuments] = useState([]) + const [pages, setPages] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -26,8 +26,8 @@ export default function SettingsPage() { email: sessionUser.email, full_name: sessionUser.user_metadata?.full_name }) - const docs = await fetchOwnedDocumentsWithMembers(sessionUser.id) - setDocuments(docs) + const pages = await fetchOwnedPagesWithMembers(sessionUser.id) + setPages(pages) } catch (err) { console.error(err) router.push('/login') @@ -41,5 +41,5 @@ export default function SettingsPage() { if (loading) return if (!user) return null - return + return } diff --git a/components/dashboard.tsx b/components/dashboard.tsx index 33e4fb4..5c128af 100644 --- a/components/dashboard.tsx +++ b/components/dashboard.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { useRouter } from 'next/navigation' -import { DocumentItem, MemberDocumentItem } from '@/types' +import { MemberPageItem, Page } from '@/types' import { supabase } from '@/lib/supabase' -import { fetchOwnedDocuments, fetchSharedDocuments, createDocument, deleteDocument, updateDocumentTitle, fetchPendingInvitations } from '@/services/db' +import { ensureWorkspace, fetchWorkspacePages, fetchSharedPages, createPage, deletePage, updatePageTitle, fetchPendingPageInvitations } from '@/services/graph' import Invitations from './invitations' import ProfileMenu from './profile-menu' import ThemeToggle from './theme-toggle' @@ -27,8 +27,8 @@ import { GlobalHeaderSlot } from './layout/global-header-context' export default function Dashboard({ user }: DashboardProps) { const router = useRouter() - const [myDocs, setMyDocs] = useState([]) - const [sharedDocs, setSharedDocs] = useState([]) + const [myPages, setMyPages] = useState([]) + const [sharedPages, setSharedPages] = useState([]) const [pendingInvitesCount, setPendingInvitesCount] = useState(0) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState(false) @@ -40,11 +40,11 @@ export default function Dashboard({ user }: DashboardProps) { const [filterDate, setFilterDate] = useState('all') const [sortBy, setSortBy] = useState('newest') - const myDocsScrollRef = useRef(null) - const sharedDocsScrollRef = useRef(null) + const myPagesScrollRef = useRef(null) + const sharedPagesScrollRef = useRef(null) const fetchRequestIdRef = useRef(0) - const [myDocsScrollState, setMyDocsScrollState] = useState({ left: false, right: false }) - const [sharedDocsScrollState, setSharedDocsScrollState] = useState({ left: false, right: false }) + const [myPagesScrollState, setMyPagesScrollState] = useState({ left: false, right: false }) + const [sharedPagesScrollState, setSharedPagesScrollState] = useState({ left: false, right: false }) const scrollContainer = (ref: React.RefObject, direction: 'left' | 'right') => { if (ref.current) { @@ -99,23 +99,24 @@ export default function Dashboard({ user }: DashboardProps) { // Removed infinite scroll listener in favor of Load More buttons - const fetchDocuments = useCallback(async () => { + const fetchPages = useCallback(async () => { const requestId = ++fetchRequestIdRef.current setLoading(true) setFetchError(false) try { + const workspace = await ensureWorkspace(user.id) const [owned, shared, invites] = await Promise.all([ - fetchOwnedDocuments(user.id), - fetchSharedDocuments(user.id), - fetchPendingInvitations(user.email) + fetchWorkspacePages(workspace.id), + fetchSharedPages(user.id), + fetchPendingPageInvitations(user.email) ]) if (requestId !== fetchRequestIdRef.current) return - setMyDocs(owned) - setSharedDocs(shared) + setMyPages(owned) + setSharedPages(shared) setPendingInvitesCount(invites.length) } catch (err) { if (requestId !== fetchRequestIdRef.current) return - console.error('Error fetching documents:', err) + console.error('Error fetching pages:', err) setFetchError(true) } finally { if (requestId === fetchRequestIdRef.current) { @@ -125,29 +126,30 @@ export default function Dashboard({ user }: DashboardProps) { }, [user.id, user.email]) useEffect(() => { - fetchDocuments() - }, [fetchDocuments]) + fetchPages() + }, [fetchPages]) - // Refetch documents when a fresh session is established. The inactivity + // Refetch pages when a fresh session is established. The inactivity // lock re-authenticates via signInWithPassword (same user.id), so the // [user.id] effect never re-runs; without this, the dashboard stays blank // until a full page reload. useEffect(() => { const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => { if (event === 'SIGNED_IN') { - fetchDocuments() + fetchPages() } }) return () => subscription.unsubscribe() - }, [fetchDocuments]) + }, [fetchPages]) - const handleCreateDocument = async () => { + const handleCreatePage = async () => { try { - const doc = await createDocument(user.id) - router.push(`/doc/${doc.id}`) + const workspace = await ensureWorkspace(user.id) + const page = await createPage(workspace.id, user.id) + router.push(`/page/${page.id}`) } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) - toast.error(`Failed to create document: ${message}`) + toast.error(`Failed to create page: ${message}`) } } @@ -162,12 +164,12 @@ export default function Dashboard({ user }: DashboardProps) { const executeDelete = async () => { if (!documentToDelete) return try { - await deleteDocument(documentToDelete) - setMyDocs(prev => prev.filter(doc => doc.id !== documentToDelete)) - toast.success('Document deleted successfully') + await deletePage(documentToDelete) + setMyPages(prev => prev.filter(page => page.id !== documentToDelete)) + toast.success('Page deleted successfully') } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) - toast.error(`Failed to delete document: ${message}`) + toast.error(`Failed to delete page: ${message}`) } finally { setDocumentToDelete(null) } @@ -175,12 +177,12 @@ export default function Dashboard({ user }: DashboardProps) { const handleRenameSubmit = async (id: string, newTitle: string) => { try { - await updateDocumentTitle(id, newTitle) - setMyDocs(prev => prev.map(doc => doc.id === id ? { ...doc, title: newTitle } : doc)) + await updatePageTitle(id, newTitle) + setMyPages(prev => prev.map(page => page.id === id ? { ...page, title: newTitle } : page)) setEditingTitleId(null) } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) - toast.error(`Failed to rename document: ${message}`) + toast.error(`Failed to rename page: ${message}`) } } @@ -201,43 +203,43 @@ export default function Dashboard({ user }: DashboardProps) { const dateLimit = getDateLimit() return docs .filter(doc => { - const docDate = new Date(doc[dateField] || (doc.documents && doc.documents[dateField])) + const docDate = new Date(doc[dateField] || (doc.documents?.[dateField] ?? doc.pages?.[dateField])) return docDate >= dateLimit }) .filter(doc => { - const title = doc[titleField] || (doc.documents && doc.documents[titleField]) || '' + const title = doc[titleField] || (doc.documents?.[titleField] ?? doc.pages?.[titleField]) || '' return title.toLowerCase().includes(searchQuery.toLowerCase()) }) .sort((a, b) => { if (sortBy === 'newest') { - const dateA = new Date(a[dateField] || (a.documents && a.documents[dateField])).getTime() - const dateB = new Date(b[dateField] || (b.documents && b.documents[dateField])).getTime() + const dateA = new Date(a[dateField] || (a.documents?.[dateField] ?? a.pages?.[dateField])).getTime() + const dateB = new Date(b[dateField] || (b.documents?.[dateField] ?? b.pages?.[dateField])).getTime() return dateB - dateA } if (sortBy === 'oldest') { - const dateA = new Date(a[dateField] || (a.documents && a.documents[dateField])).getTime() - const dateB = new Date(b[dateField] || (b.documents && b.documents[dateField])).getTime() + const dateA = new Date(a[dateField] || (a.documents?.[dateField] ?? a.pages?.[dateField])).getTime() + const dateB = new Date(b[dateField] || (b.documents?.[dateField] ?? b.pages?.[dateField])).getTime() return dateA - dateB } if (sortBy === 'alphabetical') { - const titleA = (a[titleField] || (a.documents && a.documents[titleField]) || '').toLowerCase() - const titleB = (b[titleField] || (b.documents && b.documents[titleField]) || '').toLowerCase() + const titleA = (a[titleField] || (a.documents?.[titleField] ?? a.pages?.[titleField]) || '').toLowerCase() + const titleB = (b[titleField] || (b.documents?.[titleField] ?? b.pages?.[titleField]) || '').toLowerCase() return titleA.localeCompare(titleB) } return 0 }) }, [getDateLimit, searchQuery, sortBy]) - const filteredMyDocs = useMemo(() => applyFiltersAndSort(myDocs, 'updated_at', 'title') as DocumentItem[], [applyFiltersAndSort, myDocs]) - const filteredSharedDocs = useMemo(() => applyFiltersAndSort(sharedDocs, 'updated_at', 'title') as MemberDocumentItem[], [applyFiltersAndSort, sharedDocs]) + const filteredMyPages = useMemo(() => applyFiltersAndSort(myPages, 'updated_at', 'title') as Page[], [applyFiltersAndSort, myPages]) + const filteredSharedPages = useMemo(() => applyFiltersAndSort(sharedPages, 'updated_at', 'title') as MemberPageItem[], [applyFiltersAndSort, sharedPages]) useEffect(() => { - if (myDocsScrollRef.current) handleScrollCheck(myDocsScrollRef.current, setMyDocsScrollState) - }, [filteredMyDocs]) + if (myPagesScrollRef.current) handleScrollCheck(myPagesScrollRef.current, setMyPagesScrollState) + }, [filteredMyPages]) useEffect(() => { - if (sharedDocsScrollRef.current) handleScrollCheck(sharedDocsScrollRef.current, setSharedDocsScrollState) - }, [filteredSharedDocs]) + if (sharedPagesScrollRef.current) handleScrollCheck(sharedPagesScrollRef.current, setSharedPagesScrollState) + }, [filteredSharedPages]) return (

@@ -262,7 +264,7 @@ export default function Dashboard({ user }: DashboardProps) {

Notifications

- +
)} @@ -297,24 +299,24 @@ export default function Dashboard({ user }: DashboardProps) { ) : fetchError ? (
error -

Couldn't load your documents

+

Couldn't load your pages

- Something went wrong while fetching your documents. Please try again. + Something went wrong while fetching your pages. Please try again.

-
- ) : filteredMyDocs.length === 0 && filteredSharedDocs.length === 0 && pendingInvitesCount === 0 ? ( - myDocs.length === 0 && sharedDocs.length === 0 ? ( + ) : filteredMyPages.length === 0 && filteredSharedPages.length === 0 && pendingInvitesCount === 0 ? ( + myPages.length === 0 && sharedPages.length === 0 ? (
- No documents + No pages

Welcome to Lekhan!

- You haven't created any documents yet. Create your first document to start collaborating with your team! + You haven't created any pages yet. Create your first page to start collaborating with your team!

- @@ -324,7 +326,7 @@ export default function Dashboard({ user }: DashboardProps) { No results

No results found

- No documents match your current search or filter criteria. + No pages match your current search or filter criteria.

) @@ -344,18 +346,18 @@ export default function Dashboard({ user }: DashboardProps) {
- {pendingInvitesCount > 0 && myDocs.length === 0 && sharedDocs.length === 0 && ( - + {pendingInvitesCount > 0 && myPages.length === 0 && sharedPages.length === 0 && ( + )} - {/* My Documents */} + {/* My Pages */}
folder -

Documents

+

Pages

- {(myDocs.length > 0 || sharedDocs.length > 0) && ( + {(myPages.length > 0 || sharedPages.length > 0) && (
- {filteredMyDocs.length === 0 ? ( + {filteredMyPages.length === 0 ? (
- No documents -

No owned documents yet

+ No pages +

No pages yet

- {myDocs.length === 0 - ? "You haven't created any documents. Use the New button to start collaborating!" - : "No documents match your search query."} + {myPages.length === 0 + ? "You haven't created any pages. Use the New button to start collaborating!" + : "No pages match your search query."}

) : (
- {myDocsScrollState.left && ( + {myPagesScrollState.left && ( )}
handleScrollCheck(e.currentTarget, setMyDocsScrollState)} + ref={myPagesScrollRef} + onScroll={(e) => handleScrollCheck(e.currentTarget, setMyPagesScrollState)} className="flex overflow-x-auto hide-scrollbar gap-gutter pb-lg snap-x" > - {filteredMyDocs.map((doc) => ( + {filteredMyPages.map((page) => (
router.push(`/doc/${doc.id}`)} + key={page.id} + onClick={() => router.push(`/page/${page.id}`)} className="min-w-[260px] w-[260px] sm:min-w-[280px] sm:w-[280px] shrink-0 snap-start bg-white dark:bg-surface border border-black/10 dark:border-white/10 rounded-2xl overflow-hidden group/card opacity-0 animate-fade-in-up stagger-2 premium-transition hover:shadow-xl hover:-translate-y-1 cursor-pointer flex flex-col relative" > {/* Header Block */} @@ -455,20 +457,20 @@ export default function Dashboard({ user }: DashboardProps) { - {activeActionMenuId === doc.id && ( + {activeActionMenuId === page.id && (
- @@ -481,9 +483,9 @@ export default function Dashboard({ user }: DashboardProps) { {/* Card Body */}
handleRenameSubmit(doc.id, newTitle)} + initialValue={page.title} + isEditingProp={editingTitleId === page.id} + onSave={(newTitle) => handleRenameSubmit(page.id, newTitle)} onCancelEdit={() => setEditingTitleId(null)} containerClassName="w-full flex-1 min-w-0 mb-2" textClassName="font-title-md font-bold text-on-surface group-hover/card:text-primary premium-transition truncate w-full px-0 py-0 hover:bg-transparent" @@ -493,16 +495,16 @@ export default function Dashboard({ user }: DashboardProps) {

schedule - {new Date(doc.updated_at).toLocaleDateString()} + {new Date(page.updated_at).toLocaleDateString()}

))}
- {myDocsScrollState.right && filteredMyDocs.length > 0 && ( + {myPagesScrollState.right && filteredMyPages.length > 0 && (
- {filteredSharedDocs.length === 0 ? ( + {filteredSharedPages.length === 0 ? (
- No shared documents -

No shared documents yet

+ No shared pages +

No shared pages yet

- {sharedDocs.length === 0 - ? "Documents appear here when you're invited to collaborate." - : "No shared documents match your search query."} + {sharedPages.length === 0 + ? "Pages appear here when you're invited to collaborate." + : "No shared pages match your search query."}

) : (
- {sharedDocsScrollState.left && ( + {sharedPagesScrollState.left && ( )}
handleScrollCheck(e.currentTarget, setSharedDocsScrollState)} + ref={sharedPagesScrollRef} + onScroll={(e) => handleScrollCheck(e.currentTarget, setSharedPagesScrollState)} className="flex overflow-x-auto hide-scrollbar gap-gutter pb-lg snap-x" > - {filteredSharedDocs.map((item) => ( + {filteredSharedPages.map((item) => (
router.push(`/doc/${item.documents.id}`)} + key={item.pages.id} + onClick={() => router.push(`/page/${item.pages.id}`)} className="min-w-[260px] w-[260px] sm:min-w-[280px] sm:w-[280px] shrink-0 snap-start bg-white dark:bg-surface border border-black/10 dark:border-white/10 rounded-2xl overflow-hidden group/card opacity-0 animate-fade-in-up stagger-3 premium-transition hover:shadow-xl hover:-translate-y-1 cursor-pointer flex flex-col relative" >
@@ -561,11 +563,11 @@ export default function Dashboard({ user }: DashboardProps) {
-

{item.documents.title}

+

{item.pages.title}

schedule - {new Date(item.documents.updated_at).toLocaleDateString()} + {new Date(item.pages.updated_at).toLocaleDateString()}

{item.role}
@@ -573,9 +575,9 @@ export default function Dashboard({ user }: DashboardProps) {
))}
- {sharedDocsScrollState.right && filteredSharedDocs.length > 0 && ( + {sharedPagesScrollState.right && filteredSharedPages.length > 0 && ( !open && setDocumentToDelete(null)} - title="Delete Document" - description="Are you sure you want to delete this document? This action cannot be undone." + title="Delete Page" + description="Are you sure you want to delete this page? This action cannot be undone." onConfirm={executeDelete} confirmText="Delete" /> diff --git a/components/editor-workspace.tsx b/components/editor-workspace.tsx index 9d38081..8b21c8c 100644 --- a/components/editor-workspace.tsx +++ b/components/editor-workspace.tsx @@ -42,7 +42,8 @@ import { PromptDialog } from './ui/prompt-dialog' import * as Y from 'yjs' import { Mention } from '@tiptap/extension-mention' import MentionList, { MentionItem } from './mention-list' -import { fetchDocumentDetails, fetchMemberRole, updateDocumentTitle, fetchMentionableCollaborators, getUserAICredits } from '@/services/db' +import { fetchPageDetails, fetchPageMemberRole, updatePageTitle, fetchMentionablePageCollaborators } from '@/services/graph' +import { getUserAICredits } from '@/services/db' import { Table } from '@tiptap/extension-table' import { TableRow } from '@tiptap/extension-table-row' @@ -65,7 +66,7 @@ import { Document } from '@tiptap/extension-document' import { Placeholder } from '@tiptap/extension-placeholder' interface EditorWorkspaceProps { - documentId: string + pageId: string initialTitle: string token: string currentUser: { @@ -155,7 +156,7 @@ function getInitials(nameOrEmail: string) { } export default function EditorWorkspace({ - documentId, + pageId, initialTitle, token, currentUser, @@ -169,6 +170,7 @@ export default function EditorWorkspace({ const [previewDoc, setPreviewDoc] = useState(null) const [previewVersionName, setPreviewVersionName] = useState(null) const [isViewer, setIsViewer] = useState(null) + const [ownerId, setOwnerId] = useState(null) const [theme, setTheme] = useState<'light' | 'dark'>('dark') const [isLinkPromptOpen, setIsLinkPromptOpen] = useState(false) const [isLekhanBotOpen, setIsLekhanBotOpen] = useState(false) @@ -238,16 +240,16 @@ export default function EditorWorkspace({ useEffect(() => { const loadMentionables = async () => { try { - const collabs = await fetchMentionableCollaborators(documentId) + const collabs = await fetchMentionablePageCollaborators(pageId) setMentionables(collabs.map(c => ({ id: c.id, name: c.full_name || c.email, email: c.email, avatarUrl: c.avatar_url }))) } catch (err) { console.error('Error fetching mentionables:', err) } } - if (documentId) { + if (pageId) { loadMentionables() } - }, [documentId]) + }, [pageId]) const handleOpenLekhanBot = useCallback(() => { setDiffPreview(null) @@ -284,14 +286,15 @@ export default function EditorWorkspace({ useEffect(() => { const checkRole = async () => { try { - const doc = await fetchDocumentDetails(documentId) - if (doc && doc.owner_id === currentUser.id) { + const page = await fetchPageDetails(pageId) + setOwnerId(page.owner_id) + if (page && page.owner_id === currentUser.id) { setIsViewer(false) return } - const role = await fetchMemberRole(documentId, currentUser.id) - if (role === 'editor') { + const role = await fetchPageMemberRole(pageId, currentUser.id) + if (role === 'editor' || role === 'owner') { setIsViewer(false) } else { setIsViewer(true) @@ -302,7 +305,7 @@ export default function EditorWorkspace({ } } checkRole() - }, [documentId, currentUser.id]) + }, [pageId, currentUser.id]) const previewEditor = useEditor({ extensions: [ @@ -337,7 +340,7 @@ export default function EditorWorkspace({ hasUnsyncedChanges, provider, isLocalSynced, - } = useEditorCollab(documentId, token, collabUser) + } = useEditorCollab(pageId, token, collabUser) useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -684,7 +687,7 @@ export default function EditorWorkspace({ const handleSaveTitle = async (newTitle: string) => { setTitle(newTitle) - await updateDocumentTitle(documentId, newTitle) + await updatePageTitle(pageId, newTitle) } if (!ydoc || !editor || isViewer === null || !isLocalSynced) { @@ -1038,7 +1041,7 @@ export default function EditorWorkspace({ setIsHistoryOpen(false)} - documentId={documentId} + documentId={pageId} ydoc={ydoc} token={token} isViewer={isViewer} @@ -1066,9 +1069,10 @@ export default function EditorWorkspace({ setIsShareOpen(false)} - documentId={documentId} + documentId={pageId} documentTitle={title} userId={currentUser.id} + isOwner={currentUser.id === ownerId} /> ([]) + const [invites, setInvites] = useState([]) const [loading, setLoading] = useState(true) const fetchInvitations = async () => { try { - const data = await fetchPendingInvitations(userEmail) + const data = await fetchPendingPageInvitations(userEmail) setInvites(data) } catch (err) { console.error('Error fetching invitations:', err) @@ -39,9 +39,9 @@ export default function Invitations({ } }, [userEmail]) - const handleAccept = async (invite: DocumentInvitation) => { + const handleAccept = async (invite: PageInvitation) => { try { - await acceptInvitation(invite, userId) + await acceptPageInvitation(invite, userId) toast.success('Invitation accepted!') fetchInvitations() onRefresh() @@ -53,9 +53,10 @@ export default function Invitations({ const handleDecline = async (inviteId: string) => { try { - await declineInvitation(inviteId) + await declinePageInvitation(inviteId) toast.success('Invitation declined') fetchInvitations() + onRefresh() } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) toast.error(`Failed to decline: ${message}`) @@ -82,7 +83,7 @@ export default function Invitations({ {invites.map((invite) => (

- {invite.profiles?.full_name || invite.profiles?.email} invited you to edit "{invite.documents?.title || 'Untitled'}" + {invite.profiles?.full_name || invite.profiles?.email} invited you to edit "{invite.pages?.title || 'Untitled'}"

@@ -110,7 +111,7 @@ export default function Invitations({

Invitation to join{' '} - {invite.documents?.title || 'Untitled'} + {invite.pages?.title || 'Untitled'} {' '} as {invite.role}

diff --git a/components/settings-client.tsx b/components/settings-client.tsx index 5cc908a..331a381 100644 --- a/components/settings-client.tsx +++ b/components/settings-client.tsx @@ -7,22 +7,24 @@ import { toast } from 'sonner' import { supabase } from '@/lib/supabase' import { getUserAICredits, - removeDocumentMember, - fetchOwnedDocumentsWithMembers, type UserAICredits, } from '@/services/db' +import { removePageMember, updatePageMemberRole, fetchOwnedPagesWithMembers } from '@/services/graph' import BYOKSettings from '@/components/byok-settings' import PricingMatrix from '@/components/pricing-plans' +import { CustomSelect } from './ui/custom-select' + +const EMPTY_PAGES: any[] = [] export default function SettingsClient({ user, - documents: initialDocuments = [], - setDocuments: setParentDocuments, + pages: initialPages = EMPTY_PAGES, + setPages: setParentPages, }: { user: { id?: string; email: string; full_name?: string } token?: string - documents?: any[] - setDocuments?: React.Dispatch> + pages?: any[] + setPages?: React.Dispatch> }) { const router = useRouter() const [activeTab, setActiveTab] = useState<'profile' | 'collaborators' | 'usage' | 'billing'>('profile') @@ -33,12 +35,12 @@ export default function SettingsClient({ const [loading, setLoading] = useState(false) const [currentPage, setCurrentPage] = useState(1) - // Documents State for Collaborators tab - const [documentsState, setDocumentsState] = useState(initialDocuments) + // Pages State for Collaborators tab + const [pagesState, setPagesState] = useState(initialPages) useEffect(() => { - setDocumentsState(initialDocuments) - }, [initialDocuments]) + setPagesState(initialPages) + }, [initialPages]) // Real DB AI Credits const [aiCredits, setAiCredits] = useState({ @@ -105,15 +107,15 @@ export default function SettingsClient({ } } - const handleRemoveMember = async (documentId: string, memberUserId: string) => { + const handleRemoveMember = async (pageId: string, memberUserId: string) => { try { - await removeDocumentMember(documentId, memberUserId) + await removePageMember(pageId, memberUserId) if (user?.id) { - const updatedDocs = await fetchOwnedDocumentsWithMembers(user.id) - if (setParentDocuments) { - setParentDocuments(updatedDocs) + const updatedPages = await fetchOwnedPagesWithMembers(user.id) + if (setParentPages) { + setParentPages(updatedPages) } - setDocumentsState(updatedDocs) + setPagesState(updatedPages) } toast.success('Collaborator removed successfully') } catch { @@ -121,10 +123,24 @@ export default function SettingsClient({ } } + const handleRoleChange = async (pageId: string, memberUserId: string, role: 'editor' | 'viewer') => { + try { + await updatePageMemberRole(pageId, memberUserId, role) + setPagesState(prev => prev.map((page: any) => + page.id === pageId + ? { ...page, page_members: (page.page_members || []).map((m: any) => m.user_id === memberUserId ? { ...m, role } : m) } + : page + )) + toast.success('Role updated successfully') + } catch { + toast.error('Failed to update role') + } + } + // Pagination for Collaborations const itemsPerPage = 5 - const totalPages = Math.ceil(documentsState.length / itemsPerPage) || 1 - const paginatedDocuments = documentsState.slice( + const totalPages = Math.ceil(pagesState.length / itemsPerPage) || 1 + const paginatedPages = pagesState.slice( (currentPage - 1) * itemsPerPage, currentPage * itemsPerPage ) @@ -253,16 +269,16 @@ export default function SettingsClient({

Manage Collaborators

-

View and manage access to documents you own.

+

View and manage access to pages you own.

- {documentsState.length === 0 ? ( + {pagesState.length === 0 ? (
-

You don't own any documents yet.

+

You don't own any pages yet.

) : (
- {paginatedDocuments.map((doc: any) => { - const members = doc.document_members || [] + {paginatedPages.map((doc: any) => { + const members = doc.page_members || [] return (
-

{doc.title || 'Untitled Document'}

+

{doc.title || 'Untitled Page'}

{members.length} {members.length === 1 ? 'collaborator' : 'collaborators'} @@ -281,30 +297,44 @@ export default function SettingsClient({ ) : (
    {members.map((member: { user_id: string; role: string; profiles?: { full_name?: string; email: string } }) => ( -
  • -
    -
    - {(member.profiles?.full_name || member.profiles?.email || '?').charAt(0)} -
    -
    -
    - {member.profiles?.full_name || member.profiles?.email} -
    -
    - {member.profiles?.email} • {member.role} -
    -
    -
    - -
  • +
  • +
    +
    + {(member.profiles?.full_name || member.profiles?.email || '?').charAt(0)} +
    +
    +
    + {member.profiles?.full_name || member.profiles?.email} +
    +
    + {member.profiles?.email} • {member.role} +
    +
    +
    + {member.role !== 'owner' && ( +
    + handleRoleChange(doc.id, member.user_id, val as 'editor' | 'viewer')} + options={[ + { label: 'Editor', value: 'editor' }, + { label: 'Viewer', value: 'viewer' }, + ]} + triggerClassName="h-7 w-[100px] bg-transparent border border-black/10 dark:border-white/10 rounded-lg text-[10px] font-medium text-on-surface px-2 focus:ring-0" + contentClassName="w-[100px]" + /> + +
    + )} +
  • ))}
)} diff --git a/components/share-modal.tsx b/components/share-modal.tsx index 037b8d1..29cb094 100644 --- a/components/share-modal.tsx +++ b/components/share-modal.tsx @@ -3,7 +3,9 @@ import { useState, useEffect } from 'react' import { X, Copy, Mail, Globe, Lock } from 'lucide-react' import { toast } from 'sonner' -import { fetchDocumentDetails, createInvitation, updateDocumentPublicStatus, fetchPastCollaborators } from '@/services/db' +import { fetchPageDetails, updatePagePublicStatus, createPageInvitation, fetchPageMembers, removePageMember, updatePageMemberRole } from '@/services/graph' +import { fetchPastCollaborators } from '@/services/db' +import { PageMember } from '@/types' import { CustomSelect } from './ui/custom-select' interface ShareModalProps { @@ -12,6 +14,7 @@ interface ShareModalProps { documentId: string documentTitle: string userId: string + isOwner: boolean } export default function ShareModal({ @@ -20,6 +23,7 @@ export default function ShareModal({ documentId, documentTitle, userId, + isOwner, }: ShareModalProps) { const [email, setEmail] = useState('') const [role, setRole] = useState<'editor' | 'viewer'>('editor') @@ -27,29 +31,69 @@ export default function ShareModal({ const [loading, setLoading] = useState(false) const [inviteLink, setInviteLink] = useState(null) const [pastCollaborators, setPastCollaborators] = useState<{email: string; full_name: string}[]>([]) + const [members, setMembers] = useState([]) + const [membersLoading, setMembersLoading] = useState(false) useEffect(() => { - if (isOpen) { - fetchDocPublicState() - fetchCollaborators() + if (!isOpen) return + + let cancelled = false + setMembersLoading(true) + + const load = async () => { + try { + const data = await fetchPageDetails(documentId) + if (cancelled) return + setIsPublic(data.is_public) + } catch (err) { + console.error('Error fetching doc public state:', err) + } + try { + const collabs = await fetchPastCollaborators(userId) + if (cancelled) return + setPastCollaborators(collabs) + } catch (err) { + console.error('Error fetching past collaborators:', err) + } + try { + const memberData = await fetchPageMembers(documentId) + if (cancelled) return + setMembers(memberData) + } catch (err) { + console.error('Error fetching page members:', err) + } finally { + if (!cancelled) { + setMembersLoading(false) + } + } } - }, [isOpen, documentId]) - const fetchDocPublicState = async () => { + load() + + return () => { + cancelled = true + } + }, [isOpen, documentId, userId]) + + const handleRemoveMember = async (member: PageMember) => { try { - const data = await fetchDocumentDetails(documentId) - setIsPublic(data.is_public) - } catch (err) { - console.error('Error fetching doc public state:', err) + await removePageMember(documentId, member.user_id) + setMembers(prev => prev.filter(m => m.user_id !== member.user_id)) + toast.success('Member removed') + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Failed to remove member: ${message}`) } } - const fetchCollaborators = async () => { + const handleRoleChange = async (member: PageMember, role: 'editor' | 'viewer') => { try { - const collabs = await fetchPastCollaborators(userId) - setPastCollaborators(collabs) - } catch (err) { - console.error('Error fetching past collaborators:', err) + await updatePageMemberRole(documentId, member.user_id, role) + setMembers(prev => prev.map(m => m.user_id === member.user_id ? { ...m, role } : m)) + toast.success('Role updated') + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Failed to update role: ${message}`) } } @@ -60,7 +104,7 @@ export default function ShareModal({ try { const token = crypto.randomUUID() - await createInvitation(documentId, userId, email, role, token) + await createPageInvitation(documentId, userId, email, role, token) // Generate the direct link const generatedLink = `${window.location.origin}/invite/${token}` @@ -80,7 +124,7 @@ export default function ShareModal({ setIsPublic(nextState) try { - await updateDocumentPublicStatus(documentId, nextState) + await updatePagePublicStatus(documentId, nextState) } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err) setIsPublic(!nextState) @@ -109,7 +153,7 @@ export default function ShareModal({
-

Share Document

+

Share Page

+ {/* 1.5 Members */} +
+

+ Members ({members.length}) +

+ {membersLoading ? ( +

Loading members...

+ ) : members.length === 0 ? ( +

No collaborators added yet.

+ ) : ( +
    + {members.map((member) => ( +
  • +
    +
    + {(member.profiles?.full_name || member.profiles?.email || '?').charAt(0)} +
    +
    +
    + {member.profiles?.full_name || member.profiles?.email} +
    +
    + {member.profiles?.email} +
    +
    +
    +
    + {isOwner && member.role !== 'owner' ? ( + handleRoleChange(member, val as 'editor' | 'viewer')} + options={[ + { label: 'Editor', value: 'editor' }, + { label: 'Viewer', value: 'viewer' }, + ]} + triggerClassName='h-7 w-[100px] bg-transparent border border-black/10 dark:border-white/10 rounded-lg text-[10px] font-medium text-on-surface px-2 focus:ring-0' + contentClassName='w-[100px]' + /> + ) : ( + + {member.role === 'owner' ? 'Owner' : member.role} + + )} + {isOwner && member.role !== 'owner' && ( + + )} +
    +
  • + ))} +
+ )} +
+ {/* 2. Invite Form */}
diff --git a/components/version-history.tsx b/components/version-history.tsx index 6c2e129..ca3bd0d 100644 --- a/components/version-history.tsx +++ b/components/version-history.tsx @@ -6,7 +6,7 @@ import { supabase } from '@/lib/supabase' import { toast } from 'sonner' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { DocumentVersion } from '@/types' -import { fetchVersions } from '@/services/db' +import { fetchVersionsForEntity } from '@/services/graph' import GlobalLoader from '@/components/global-loader' @@ -41,7 +41,7 @@ export default function VersionHistory({ const loadVersions = async () => { try { - const data = await fetchVersions(documentId) + const data = await fetchVersionsForEntity(documentId) setVersions(data) } catch (err) { console.error('Error fetching versions:', err) @@ -88,7 +88,7 @@ export default function VersionHistory({ .from('document_versions') .insert({ id: versionId, - document_id: documentId, + page_id: documentId, version_name: newVersionName.trim(), storage_path: `${documentId}/versions/${versionId}.bin`, created_by: user.id, @@ -305,7 +305,7 @@ export default function VersionHistory({ open={!!versionToRestore} onOpenChange={(open) => { if (!open) setVersionToRestore(null) }} title="Restore Version" - description={`Are you sure you want to restore "${versionToRestore?.version_name}"? Unsaved changes in the current document will be replaced.`} + description={`Are you sure you want to restore "${versionToRestore?.version_name}"? Unsaved changes in the current page will be replaced.`} confirmText="Restore" cancelText="Cancel" onConfirm={executeRestore} diff --git a/docs/superpowers/plans/2026-08-15-p2-client-cutover.md b/docs/superpowers/plans/2026-08-15-p2-client-cutover.md new file mode 100644 index 0000000..1ee813b --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-p2-client-cutover.md @@ -0,0 +1,2233 @@ +# P2: Client Cutover onto the Pages Graph Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the entire client (dashboard, editor, share modal, invitations, settings, version history) from the legacy `documents`/`document_members`/`document_invitations`/`document_versions` data flow onto `pages`/`page_members`/`page_invitations`, after making auth and RLS agree on page-only authority. + +**Architecture:** One migration (`20260815000000_page_only_authority.sql`) rewrites `can_access_page` (owner OR public OR `page_members`), restores the pages branch of `can_access_document_storage` (dropped in P1's `20260814000002`), adds `page_id` to `document_versions` with pages-aware policies, creates `page_invitations` with RLS, extends `page_members` (owner-only update policy + invitee self-insert), and converts pending legacy invites. The sync server's `verifyUserRole` drops the live `document_members` fallback (page-only authority; authenticated strangers get `viewer` on public pages). A client service layer extends `services/graph.ts`; then each UI surface swaps its data source while preserving UX. Legacy tables stay in the DB as the rollback path; the `deletePage`/`updatePagePublicStatus` documents mirrors are retained. + +**Tech Stack:** Next.js 16 + React 19, Supabase (Postgres + Storage + RLS), CommonJS sync server (y-websocket/yjs), Vitest (jsdom, globals: true, setup `tests/unit/setup.ts`), Tailwind/shadcn-style components, lucide-react, sonner. + +## Global Constraints + +- Legacy `documents`/`document_members`/`document_invitations` tables are **never dropped or altered destructively** — they remain as the rollback path. +- Migration file naming: `supabase/migrations/20260815000000_.sql`. **Never edit already-applied migration files.** +- Server files (`server/*.js`) are CommonJS. Client code is TypeScript ES modules. +- Client DB access goes through the `supabase` singleton from `lib/supabase.ts`. +- AI credits/plan helpers (`getUserAICredits`, `getPlanCollaboratorLimit`, `getPlanMaxDocuments`, `deductUserAICredits`) stay in `services/db.ts` and keep working — they are NOT retired in P2. +- Storage bucket object path stays `{entityId}/main_state.bin`; page ids are the storage keys. +- Verification gates for EVERY task: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH"` (npm not on PATH), then focused vitest files ONLY (full `npm run test` OOMs/hangs on this machine), `npm run lint`, and `npm run build` (P1 lesson: vitest + eslint never type-check — `next build` is mandatory for any TS/TSX change). +- SQL verification: migrations are applied to the live project `hftipkzqbltdkrcjynad` via Supabase MCP `apply_migration` by the controller (implementers write the file only); live RLS verification uses observable SELECTs (`SET LOCAL ROLE authenticated` + `set_config('request.jwt.claims', ..., true)`) — RAISE NOTICE is invisible in MCP output. +- plpgsql: default `variable_conflict` is `error` — never name a plpgsql variable after a column in a table the function queries (42702). +- All work happens on branch `p2-client-cutover` (already created off `main@d8679ea`; spec committed at `d4c706a`). + +--- + +### Task 1: Foundation migration — page-only authority, page invites, pages-aware versions + +**Files:** +- Create: `supabase/migrations/20260815000000_page_only_authority.sql` + +**Interfaces:** +- Produces: `public.can_access_page(uuid)` (owner OR public OR `page_members`); `public.can_access_document_storage(text, text)` (pages branch restored); `public.document_versions.page_id uuid` + pages-aware select/insert/delete policies; `public.page_invitations` table + RLS (select: owner OR invitee; insert: owner; update: owner OR inviter OR invitee; delete: owner); `page_members` update policy (owner) + insert policy extended for invitee self-insert; pending `document_invitations` → `page_invitations` conversion. +- Consumes: P1 schema (`pages.source_document_id`, `page_members`, `member_role`/`invitation_status` enums, `can_access_document_storage`). + +- [ ] **Step 1: Write the migration file** + +Create `supabase/migrations/20260815000000_page_only_authority.sql` with exactly this content: + +```sql +-- P2 page-only authority: pages are governed by owner/page_members/public only. +-- Legacy document_members no longer grant anything on pages (client cutover). +-- Also: page_invitations table, pages-aware document_versions, storage pages branch. + +-- 1. can_access_page: owner OR is_public OR page_members (source_document_id/document_members branch REMOVED) +CREATE OR REPLACE FUNCTION public.can_access_page(target_page_id uuid) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid; +BEGIN + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RETURN false; + END IF; + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = target_page_id + AND ( + p.owner_id = v_uid + OR p.is_public = true + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid + ) + ) + ); +END; +$$; + +-- 2. can_access_document_storage: restore the pages branch (dropped in +-- 20260814000002), keeping the documents branch for legacy objects. +-- Resolve the entity id from the first path segment, then dispatch on whether +-- a pages row exists for it. Select: owner/public/page_members (pages) or +-- owner/public/document_members (documents). Insert/update: owner or editor +-- member. Delete: owner only. +CREATE OR REPLACE FUNCTION public.can_access_document_storage( + object_name text, + action text +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + entity_id_text text; + v_uid uuid; + v_is_page boolean; +BEGIN + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RETURN false; + END IF; + + entity_id_text := (storage.foldername(object_name))[1]; + IF entity_id_text IS NULL OR entity_id_text = '' THEN + entity_id_text := split_part(object_name, '/', 1); + END IF; + + v_is_page := EXISTS ( + SELECT 1 FROM public.pages p WHERE p.id::text = entity_id_text + ); + + IF v_is_page THEN + IF action = 'select' THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text + AND ( + p.owner_id = v_uid + OR p.is_public = true + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid + ) + ) + ); + ELSIF action IN ('insert', 'update') THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text + AND ( + p.owner_id = v_uid + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid AND m.role = 'editor' + ) + ) + ); + ELSIF action = 'delete' THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text AND p.owner_id = v_uid + ); + END IF; + ELSE + IF action = 'select' THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text + AND ( + d.owner_id = v_uid + OR d.is_public = true + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = v_uid + ) + ) + ); + ELSIF action IN ('insert', 'update') THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text + AND ( + d.owner_id = v_uid + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = v_uid AND m.role = 'editor' + ) + ) + ); + ELSIF action = 'delete' THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text AND d.owner_id = v_uid + ); + END IF; + END IF; + + RETURN false; +END; +$$; + +-- 3. document_versions: nullable page_id FK (twin-less pages can hold versions); +-- document_id must be nullable: page-backed records (version-history.tsx +-- checkpoint save, app/api/version/route.ts page branch) insert page-only +-- rows and omit document_id. Follow-up migration +-- 20260816000001_document_versions_page_only.sql drops the NOT NULL. +ALTER TABLE public.document_versions + ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES public.pages(id) ON DELETE CASCADE; + +DROP POLICY IF EXISTS select_versions ON public.document_versions; +CREATE POLICY select_versions ON public.document_versions +FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id + AND ( + p.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = auth.uid() + ) + ) + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id + AND ( + d.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = auth.uid() + ) + ) + ) +); + +DROP POLICY IF EXISTS insert_versions ON public.document_versions; +CREATE POLICY insert_versions ON public.document_versions +FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id + AND ( + p.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = auth.uid() AND m.role = 'editor' + ) + ) + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id + AND ( + d.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = auth.uid() AND m.role = 'editor' + ) + ) + ) +); + +DROP POLICY IF EXISTS delete_versions ON public.document_versions; +CREATE POLICY delete_versions ON public.document_versions +FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id AND p.owner_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id AND d.owner_id = auth.uid() + ) +); + +-- 4. page_invitations: page-level invite/accept flow (mirror of document_invitations) +CREATE TABLE IF NOT EXISTS public.page_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + page_id UUID NOT NULL REFERENCES public.pages(id) ON DELETE CASCADE, + inviter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + invitee_email TEXT NOT NULL, + role member_role NOT NULL, + token UUID NOT NULL DEFAULT gen_random_uuid(), + status invitation_status DEFAULT 'pending' NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +ALTER TABLE public.page_invitations ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS select_page_invitations ON public.page_invitations; +CREATE POLICY select_page_invitations ON public.page_invitations +FOR SELECT TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) +); + +DROP POLICY IF EXISTS insert_page_invitations ON public.page_invitations; +CREATE POLICY insert_page_invitations ON public.page_invitations +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +DROP POLICY IF EXISTS update_page_invitations ON public.page_invitations; +CREATE POLICY update_page_invitations ON public.page_invitations +FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR inviter_id = auth.uid() + OR invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) +); + +DROP POLICY IF EXISTS delete_page_invitations ON public.page_invitations; +CREATE POLICY delete_page_invitations ON public.page_invitations +FOR DELETE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +-- 5. page_members: owner-only role-change policy; insert extended for invitee +-- self-insert on acceptance of a pending page invitation (mirrors legacy). +DROP POLICY IF EXISTS insert_page_members ON public.page_members; +CREATE POLICY insert_page_members ON public.page_members +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR ( + user_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.page_invitations pi + WHERE pi.page_id = page_id + AND pi.invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) + AND pi.status = 'pending' + ) + ) +); + +DROP POLICY IF EXISTS update_page_members ON public.page_members; +CREATE POLICY update_page_members ON public.page_members +FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +-- 6. Convert pending legacy invites so in-flight invite links keep working. +INSERT INTO public.page_invitations (page_id, inviter_id, invitee_email, role, token, status, created_at) +SELECT p.id, di.inviter_id, di.invitee_email, di.role, di.token, di.status, di.created_at +FROM public.document_invitations di +JOIN public.pages p ON p.source_document_id = di.document_id +WHERE di.status = 'pending' +ON CONFLICT DO NOTHING; +``` + +- [ ] **Step 2: Syntax check the file** + +Run: `node --check` does not apply to SQL. Instead verify balanced statements: `awk '/^[[:space:]]*$/{next} {if ($0 ~ /;\s*$/) c++} END {print c " statement-enders"}' supabase/migrations/20260815000000_page_only_authority.sql` +Expected: 6 blocks — 2 `CREATE OR REPLACE FUNCTION`, 1 `ALTER TABLE`, 6 `CREATE POLICY`, 1 `CREATE TABLE`, 1 `INSERT`. Review the output count is sane (≥ 10). + +- [ ] **Step 3: Controller gate — apply the migration to the live project via Supabase MCP** + +Run (controller, NOT the implementer): `supabase_apply_migration` with name `page_only_authority` and the file content from Step 1. +Expected: success (migration `20260815000000` recorded on project `hftipkzqbltdkrcjynad`). + +- [ ] **Step 4: Controller gate — verify schema landed** + +Run: `supabase_list_tables` (verbose, schema `public`) and confirm: `page_invitations` exists; `document_versions` has `page_id`; `page_members` has an update policy. +Also run `supabase_execute_sql`: +```sql +SELECT proname, prosrc LIKE '%document_members%' AS references_doc_members +FROM pg_proc WHERE proname = 'can_access_page'; +``` +Expected: `can_access_page` → `references_doc_members = false`. + +- [ ] **Step 5: Commit** + +```bash +git add supabase/migrations/20260815000000_page_only_authority.sql +git commit -m "feat(db): page-only authority, page invitations, pages-aware version history" +``` + +--- + +### Task 2: Server auth — page-only authority in verifyUserRole + +**Files:** +- Modify: `server/auth.js:76-107` (page branch of `verifyUserRole`) +- Test: `tests/unit/server.test.ts` (replace 2 tests in the pages suite) + +**Interfaces:** +- Consumes: `getEntityOwner(supabase, entityId)` (unchanged — pages first, documents fallback). +- Produces: `verifyUserRole(supabase, entityId, token)` where a page entity resolves to: `'owner'` (owner short-circuit, unchanged) → `page_members.role` → `'viewer'` when `is_public` (authenticated non-members) → `null`. The live `document_members` fallback is GONE. + +- [ ] **Step 1: Rewrite the failing tests** + +In `tests/unit/server.test.ts`, delete the two tests `verifyUserRole honors live document_members grants on mapped pages after migration` and `verifyUserRole denies a mapped page after the document_members grant is revoked` (lines ~216-316) and replace them with exactly: + +```ts + it('verifyUserRole ignores document_members grants on mapped pages (page-only authority)', async () => { + const mockSupabase = { + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-456' } }, error: null }), + }, + from: vi.fn().mockImplementation((table: string) => { + if (table === 'pages') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { type: 'page', owner_id: 'user-123', is_public: false }, + error: null, + }), + }), + }), + } + } + if (table === 'page_members') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } + } + return {} + }), + } as any + + const role = await auth.verifyUserRole(mockSupabase, 'page-1', 'token-1') + expect(role).toBeNull() + }) + + it('verifyUserRole grants "viewer" to an authenticated non-member on a public page', async () => { + const mockSupabase = { + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-456' } }, error: null }), + }, + from: vi.fn().mockImplementation((table: string) => { + if (table === 'pages') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { type: 'page', owner_id: 'user-123', is_public: true }, + error: null, + }), + }), + }), + } + } + if (table === 'page_members') { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } + } + return {} + }), + } as any + + const role = await auth.verifyUserRole(mockSupabase, 'page-1', 'token-1') + expect(role).toBe('viewer') + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/server.test.ts` +Expected: the two NEW tests fail (`verifyUserRole` still returns `'editor'`/`null` from the legacy fallback paths); no other test regressions. + +- [ ] **Step 3: Implement the page-only authority** + +In `server/auth.js`, replace the whole page branch (currently lines 76-107: `if (entity.type === 'page') { ... return null }`) with: + +```js + if (entity.type === 'page') { + const { data: member } = await supabase + .from('page_members') + .select('role') + .eq('page_id', entityId) + .eq('user_id', user.id) + .single() + if (member) { + return member.role + } + + // Page-only authority (P2): page_members is the sole membership source + // for pages — the legacy document_members fallback was removed so the + // sync server's role verdict always matches page RLS (can_access_page). + // Authenticated non-members get read-only access to public pages, + // matching RLS (previously they were denied while anon could read). + if (entity.is_public) { + return 'viewer' + } + + return null + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/unit/server.test.ts` +Expected: 15/15 pass (the two replacements green; `anonymous viewer on public page`, owner, page_members, documents-fallback tests unchanged). + +- [ ] **Step 5: Syntax check + lint + build** + +Run: `node --check server/auth.js && npm run lint && npm run build` +Expected: all green. (`npm run build` = `next build`; catches TS errors across the app.) + +- [ ] **Step 6: Commit** + +```bash +git add server/auth.js tests/unit/server.test.ts +git commit -m "fix(auth): page-only authority in verifyUserRole, public pages grant viewer to authenticated strangers" +``` + +--- + +### Task 3: Client types + graph service layer + +**Files:** +- Modify: `types/index.ts` (add types; fix `PageLink.workspace_id`; `DocumentVersion.page_id`) +- Modify: `services/graph.ts` (add 14 functions) +- Test: `tests/unit/db-graph.test.ts` (extend builder + new describe) + +**Interfaces:** +- Consumes: `fetchPageDetails(pageId): Promise` (exists); `getUserAICredits`/`getPlanCollaboratorLimit` from `@/services/db` (exist, unchanged). +- Produces (all `Promise`-based, throw on Supabase error, same style as existing graph.ts): + - `ensureWorkspace(userId: string): Promise` + - `fetchSharedPages(userId: string): Promise` + - `fetchPageMemberRole(pageId: string, userId: string): Promise` + - `fetchPageMembers(pageId: string): Promise` + - `removePageMember(pageId: string, userId: string): Promise` + - `updatePageMemberRole(pageId: string, userId: string, role: 'editor' | 'viewer'): Promise` + - `createPageInvitation(pageId: string, inviterId: string, inviteeEmail: string, role: 'editor' | 'viewer', token: string): Promise` + - `fetchPendingPageInvitations(email: string): Promise` + - `acceptPageInvitation(invite: PageInvitation, userId: string): Promise` + - `declinePageInvitation(inviteId: string): Promise` + - `fetchPageInvitationDetails(token: string): Promise` + - `fetchMentionablePageCollaborators(pageId: string): Promise>` + - `fetchOwnedPagesWithMembers(userId: string): Promise<(Page & { page_members: PageMember[] })[]>` + - `fetchVersionsForEntity(entityId: string): Promise` + +- [ ] **Step 1: Write the failing tests** + +In `tests/unit/db-graph.test.ts`, extend the mock builder (lines 16-32) to: + +```ts +vi.mock('@/lib/supabase', () => { + const builder: any = { + select: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: null, error: null }), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + then: vi.fn((cb) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)), + } + return { + supabase: { + from: vi.fn(() => builder), + }, + } +}) +``` + +Update `beforeEach` (lines 37-47) to also reset `in`, `or`, `maybeSingle`: +```ts + beforeEach(() => { + vi.clearAllMocks() + mockBuilder.select.mockReturnThis() + mockBuilder.insert.mockReturnThis() + mockBuilder.update.mockReturnThis() + mockBuilder.delete.mockReturnThis() + mockBuilder.eq.mockReturnThis() + mockBuilder.in.mockReturnThis() + mockBuilder.order.mockReturnThis() + mockBuilder.or.mockReturnThis() + mockBuilder.single.mockResolvedValue({ data: null, error: null }) + mockBuilder.maybeSingle.mockResolvedValue({ data: null, error: null }) + mockBuilder.then.mockImplementation((cb: any) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)) + }) +``` + +Update the import block (lines 3-14) to add the new functions: +```ts +import { + fetchWorkspaces, + fetchWorkspacePages, + createPage, + updatePageTitle, + deletePage, + updatePagePublicStatus, + fetchPageDetails, + fetchPageBacklinks, + fetchPageTags, + fetchWorkspaceGraph, + ensureWorkspace, + fetchSharedPages, + fetchPageMemberRole, + fetchPageMembers, + removePageMember, + updatePageMemberRole, + createPageInvitation, + fetchPendingPageInvitations, + acceptPageInvitation, + declinePageInvitation, + fetchPageInvitationDetails, + fetchMentionablePageCollaborators, + fetchOwnedPagesWithMembers, + fetchVersionsForEntity, +} from '@/services/graph' +``` + +Append this describe block at the end of the file (after line 164): + +```ts +describe('Graph Service P2 additions', () => { + it('ensureWorkspace returns an existing workspace', async () => { + const ws = { id: 'ws-1', name: 'My Workspace', owner_id: 'user-123', is_team: false, created_at: '', updated_at: '' } + mockBuilder.maybeSingle.mockResolvedValue({ data: ws, error: null }) + const result = await ensureWorkspace('user-123') + expect(supabase.from).toHaveBeenCalledWith('workspaces') + expect(mockBuilder.eq).toHaveBeenCalledWith('owner_id', 'user-123') + expect(result).toEqual(ws) + }) + + it('ensureWorkspace inserts a workspace when none exists', async () => { + mockBuilder.maybeSingle.mockResolvedValue({ data: null, error: null }) + mockBuilder.single.mockResolvedValue({ data: { id: 'ws-2', owner_id: 'user-123' }, error: null }) + const result = await ensureWorkspace('user-123') + expect(mockBuilder.insert).toHaveBeenCalledWith({ owner_id: 'user-123' }) + expect(result).toEqual({ id: 'ws-2', owner_id: 'user-123' }) + }) + + it('ensureWorkspace refetches when a concurrent insert hits a unique violation', async () => { + mockBuilder.maybeSingle + .mockResolvedValueOnce({ data: null, error: null }) + .mockResolvedValueOnce({ data: { id: 'ws-3', owner_id: 'user-123' }, error: null }) + mockBuilder.single.mockResolvedValue({ + data: null, + error: { code: '23505', message: 'duplicate key value violates unique constraint' }, + }) + const result = await ensureWorkspace('user-123') + expect(result).toEqual({ id: 'ws-3', owner_id: 'user-123' }) + }) + + it('fetchSharedPages queries page_members with page embed', async () => { + const shared = [{ role: 'editor', pages: { id: 'p-1', title: 'A' } }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: shared, error: null }).then(onfulfilled) + ) + const result = await fetchSharedPages('user-123') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'user-123') + expect(result).toEqual(shared) + }) + + it('fetchPageMemberRole returns the role for a member', async () => { + mockBuilder.single.mockResolvedValue({ data: { role: 'viewer' }, error: null }) + const role = await fetchPageMemberRole('p-1', 'user-123') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(role).toBe('viewer') + }) + + it('fetchPageMemberRole returns null on error', async () => { + mockBuilder.single.mockResolvedValue({ data: null, error: { message: 'no rows' } }) + const role = await fetchPageMemberRole('p-1', 'user-123') + expect(role).toBeNull() + }) + + it('fetchPageMembers returns members with profile embed', async () => { + const members = [{ id: 'm-1', page_id: 'p-1', user_id: 'u-1', role: 'editor' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: members, error: null }).then(onfulfilled) + ) + const result = await fetchPageMembers('p-1') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(result).toEqual(members) + }) + + it('removePageMember deletes the membership', async () => { + await removePageMember('p-1', 'u-9') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.delete).toHaveBeenCalled() + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'u-9') + }) + + it('updatePageMemberRole updates the role', async () => { + await updatePageMemberRole('p-1', 'u-9', 'viewer') + expect(mockBuilder.update).toHaveBeenCalledWith({ role: 'viewer' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'u-9') + }) + + it('createPageInvitation counts members and pending invites against the plan limit', async () => { + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + let counts = 0 + mockBuilder.then.mockImplementation((onfulfilled: any) => + Promise.resolve({ data: [], count: counts++, error: null }).then(onfulfilled) + ) + await createPageInvitation('p-1', 'owner-1', 'x@test.com', 'viewer', 'tok-1') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.insert).toHaveBeenCalledWith({ + page_id: 'p-1', + inviter_id: 'owner-1', + invitee_email: 'x@test.com', + role: 'viewer', + token: 'tok-1', + status: 'pending', + }) + }) + + it('fetchPendingPageInvitations filters by email and pending status', async () => { + const invites = [{ id: 'i-1', page_id: 'p-1', role: 'editor' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: invites, error: null }).then(onfulfilled) + ) + const result = await fetchPendingPageInvitations('x@test.com') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.eq).toHaveBeenCalledWith('invitee_email', 'x@test.com') + expect(mockBuilder.eq).toHaveBeenCalledWith('status', 'pending') + expect(result).toEqual(invites) + }) + + it('acceptPageInvitation inserts a member then marks the invite accepted', async () => { + const invite = { id: 'i-1', page_id: 'p-1', role: 'editor' } as PageInvitation + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + mockBuilder.then.mockResolvedValue({ data: [], count: 0, error: null }) + await acceptPageInvitation(invite, 'user-123') + expect(mockBuilder.insert).toHaveBeenCalledWith({ page_id: 'p-1', user_id: 'user-123', role: 'editor' }) + expect(mockBuilder.update).toHaveBeenCalledWith({ status: 'accepted' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('id', 'i-1') + }) + + it('declinePageInvitation marks the invite declined', async () => { + await declinePageInvitation('i-1') + expect(mockBuilder.update).toHaveBeenCalledWith({ status: 'declined' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('id', 'i-1') + }) + + it('fetchPageInvitationDetails fetches by token', async () => { + const invite = { id: 'i-1', page_id: 'p-1', token: 'tok-1' } + mockBuilder.single.mockResolvedValue({ data: invite, error: null }) + const result = await fetchPageInvitationDetails('tok-1') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.eq).toHaveBeenCalledWith('token', 'tok-1') + expect(result).toEqual(invite) + }) + + it('fetchMentionablePageCollaborators returns owner and editor members', async () => { + let calls = 0 + mockBuilder.then.mockImplementation((onfulfilled: any) => { + calls += 1 + const payload = calls === 1 + ? { data: { owner_id: 'owner-1', profiles: { id: 'owner-1', email: 'o@test.com', full_name: 'Owner' } }, error: null } + : { data: [{ role: 'editor', profiles: { id: 'ed-1', email: 'e@test.com', full_name: 'Editor' } }], error: null } + return Promise.resolve(payload).then(onfulfilled) + }) + const result = await fetchMentionablePageCollaborators('p-1') + expect(supabase.from).toHaveBeenCalledWith('pages') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(result).toEqual([ + { id: 'owner-1', email: 'o@test.com', full_name: 'Owner' }, + { id: 'ed-1', email: 'e@test.com', full_name: 'Editor' }, + ]) + }) + + it('fetchOwnedPagesWithMembers embeds page members', async () => { + const pages = [{ id: 'p-1', page_members: [{ id: 'm-1', user_id: 'u-1', role: 'editor' }] }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: pages, error: null }).then(onfulfilled) + ) + const result = await fetchOwnedPagesWithMembers('user-123') + expect(supabase.from).toHaveBeenCalledWith('pages') + expect(mockBuilder.eq).toHaveBeenCalledWith('owner_id', 'user-123') + expect(result).toEqual(pages) + }) + + it('fetchVersionsForEntity queries page_id OR document_id', async () => { + const versions = [{ id: 'v-1', version_name: 'Draft' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: versions, error: null }).then(onfulfilled) + ) + const result = await fetchVersionsForEntity('p-1') + expect(supabase.from).toHaveBeenCalledWith('document_versions') + expect(mockBuilder.or).toHaveBeenCalledWith('page_id.eq.p-1,document_id.eq.p-1') + expect(result).toEqual(versions) + }) +}) +``` + +Add the import of `PageInvitation` at the top of the file: `import { PageInvitation } from '@/types'`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/db-graph.test.ts` +Expected: the P2 additions block fails (functions don't exist); existing 10 tests still pass. + +- [ ] **Step 3: Implement the types** + +In `types/index.ts`: + +(a) Add `workspace_id: string` to `PageLink` (after `id`): +```ts +export interface PageLink { + id: string + workspace_id: string + from_page_id: string + to_page_id: string | null + to_title: string + block_id: string | null + created_at: string +} +``` + +(b) Add `page_id?: string | null` to `DocumentVersion`: +```ts +export interface DocumentVersion { + id: string + document_id: string | null + page_id?: string | null + version_name: string + created_at: string + created_by: string + profiles?: { email: string; full_name: string | null } +} +``` + +(c) Append after `Backlink`: +```ts +export type MemberRole = 'owner' | 'editor' | 'viewer' + +export interface PageMember { + id: string + page_id: string + user_id: string + role: MemberRole + created_at: string + profiles?: { id: string; email: string; full_name: string | null; avatar_url?: string | null } +} + +export interface PageInvitation { + id: string + page_id: string + inviter_id: string + invitee_email: string + role: 'editor' | 'viewer' + token: string + status: 'pending' | 'accepted' | 'declined' + created_at: string + pages?: { title: string } + profiles?: { email: string; full_name: string | null } +} + +export interface MemberPageItem { + role: MemberRole + pages: Page +} +``` + +- [ ] **Step 4: Implement the service functions** + +In `services/graph.ts`, update the import (line 2) to: +```ts +import { Backlink, DocumentVersion, MemberPageItem, Page, PageInvitation, PageLink, PageMember, PageTag, Workspace } from '@/types' +import { getPlanCollaboratorLimit, getUserAICredits } from '@/services/db' +``` + +Append these functions at the end of the file: + +```ts +export async function ensureWorkspace (userId: string): Promise { + const { data: existing, error: fetchError } = await supabase + .from('workspaces') + .select('*') + .eq('owner_id', userId) + .maybeSingle() + + if (fetchError) { + throw fetchError + } + if (existing) { + return existing as Workspace + } + + const { data, error } = await supabase + .from('workspaces') + .insert({ owner_id: userId }) + .select() + .single() + + if (error && error.code !== '23505') { + throw error + } + if (data) { + return data as Workspace + } + + // 23505: another tab created the workspace first — fetch it. + const { data: retry, error: retryError } = await supabase + .from('workspaces') + .select('*') + .eq('owner_id', userId) + .single() + + if (retryError) { + throw retryError + } + return retry as Workspace +} + +export async function fetchSharedPages (userId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('role, pages (*)') + .eq('user_id', userId) + + if (error) { + throw error + } + return (data as unknown as MemberPageItem[]) || [] +} + +export async function fetchPageMemberRole (pageId: string, userId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('role') + .eq('page_id', pageId) + .eq('user_id', userId) + .single() + + if (error) { + return null + } + return data ? (data.role as MemberRole) : null +} + +export async function fetchPageMembers (pageId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('*, profiles:user_id (id, email, full_name, avatar_url)') + .eq('page_id', pageId) + .order('created_at', { ascending: true }) + + if (error) { + throw error + } + return (data as unknown as PageMember[]) || [] +} + +export async function removePageMember (pageId: string, userId: string): Promise { + const { error } = await supabase + .from('page_members') + .delete() + .eq('page_id', pageId) + .eq('user_id', userId) + + if (error) { + throw error + } +} + +export async function updatePageMemberRole (pageId: string, userId: string, role: 'editor' | 'viewer'): Promise { + const { error } = await supabase + .from('page_members') + .update({ role }) + .eq('page_id', pageId) + .eq('user_id', userId) + + if (error) { + throw error + } +} + +export async function createPageInvitation ( + pageId: string, + inviterId: string, + inviteeEmail: string, + role: 'editor' | 'viewer', + token: string +): Promise { + try { + const pageDetails = await fetchPageDetails(pageId) + const ownerCredits = await getUserAICredits(pageDetails.owner_id) + const allowedLimit = getPlanCollaboratorLimit(ownerCredits.plan) + + const { count: memberCount } = await supabase + .from('page_members') + .select('*', { count: 'exact', head: true }) + .eq('page_id', pageId) + + const { count: inviteCount } = await supabase + .from('page_invitations') + .select('*', { count: 'exact', head: true }) + .eq('page_id', pageId) + .eq('status', 'pending') + + const totalCount = (memberCount || 0) + (inviteCount || 0) + + if (totalCount >= allowedLimit) { + throw new Error(`Collaborator limit reached for page owner's ${ownerCredits.plan.toUpperCase()} plan (max ${allowedLimit}). Upgrade plan to add more collaborators.`) + } + } catch (e: any) { + if (e.message && e.message.includes('Collaborator limit reached')) { + throw e + } + } + + const { error } = await supabase + .from('page_invitations') + .insert({ + page_id: pageId, + inviter_id: inviterId, + invitee_email: inviteeEmail, + role, + token, + status: 'pending', + }) + + if (error) { + throw error + } +} + +export async function fetchPendingPageInvitations (email: string): Promise { + const { data, error } = await supabase + .from('page_invitations') + .select(` + id, + page_id, + role, + inviter_id, + invitee_email, + pages (title), + profiles:inviter_id (email, full_name) + `) + .eq('invitee_email', email) + .eq('status', 'pending') + + if (error) { + throw error + } + return (data as unknown as PageInvitation[]) || [] +} + +export async function acceptPageInvitation (invite: PageInvitation, userId: string): Promise { + try { + const pageDetails = await fetchPageDetails(invite.page_id) + const ownerCredits = await getUserAICredits(pageDetails.owner_id) + const allowedLimit = getPlanCollaboratorLimit(ownerCredits.plan) + + const { count: memberCount } = await supabase + .from('page_members') + .select('*', { count: 'exact', head: true }) + .eq('page_id', invite.page_id) + + if ((memberCount || 0) >= allowedLimit) { + throw new Error(`Collaborator limit reached for this page's owner (${ownerCredits.plan.toUpperCase()} plan, max ${allowedLimit}).`) + } + } catch (e: any) { + if (e.message && e.message.includes('Collaborator limit reached')) { + throw e + } + } + + const { error: memberError } = await supabase + .from('page_members') + .insert({ + page_id: invite.page_id, + user_id: userId, + role: invite.role, + }) + + if (memberError && !memberError.message.includes('duplicate key')) { + throw memberError + } + + const { error: inviteError } = await supabase + .from('page_invitations') + .update({ status: 'accepted' }) + .eq('id', invite.id) + + if (inviteError) { + throw inviteError + } +} + +export async function declinePageInvitation (inviteId: string): Promise { + const { error } = await supabase + .from('page_invitations') + .update({ status: 'declined' }) + .eq('id', inviteId) + + if (error) { + throw error + } +} + +export async function fetchPageInvitationDetails (token: string): Promise { + const { data, error } = await supabase + .from('page_invitations') + .select(` + id, + page_id, + role, + status, + pages (title), + profiles:inviter_id (email, full_name) + `) + .eq('token', token) + .single() + + if (error) { + throw error + } + return data as unknown as PageInvitation +} + +export async function fetchMentionablePageCollaborators (pageId: string): Promise> { + const { data: pageData, error: pageError } = await supabase + .from('pages') + .select('owner_id, profiles:owner_id (id, email, full_name, avatar_url)') + .eq('id', pageId) + .single() + + if (pageError) throw pageError + + const { data: memberData, error: memberError } = await supabase + .from('page_members') + .select('role, profiles:user_id (id, email, full_name, avatar_url)') + .eq('page_id', pageId) + .in('role', ['editor']) + + if (memberError) throw memberError + + const collaboratorsMap = new Map() + + const ownerProfile = pageData?.profiles as unknown as { id: string; email: string; full_name?: string; avatar_url?: string } + if (ownerProfile && ownerProfile.id) { + collaboratorsMap.set(ownerProfile.id, { + id: ownerProfile.id, + email: ownerProfile.email, + full_name: ownerProfile.full_name || ownerProfile.email, + avatar_url: ownerProfile.avatar_url, + }) + } + + for (const m of (memberData || [])) { + const profile = m.profiles as unknown as { id: string; email: string; full_name?: string; avatar_url?: string } + if (profile && profile.id && !collaboratorsMap.has(profile.id)) { + collaboratorsMap.set(profile.id, { + id: profile.id, + email: profile.email, + full_name: profile.full_name || profile.email, + avatar_url: profile.avatar_url, + }) + } + } + + return Array.from(collaboratorsMap.values()) +} + +export async function fetchOwnedPagesWithMembers (userId: string): Promise<(Page & { page_members: PageMember[] })[]> { + const { data, error } = await supabase + .from('pages') + .select(` + *, + page_members ( + id, + user_id, + role, + profiles:user_id (email, full_name) + ) + `) + .eq('owner_id', userId) + .order('updated_at', { ascending: false }) + + if (error) { + throw error + } + return (data || []) as (Page & { page_members: PageMember[] })[] +} + +export async function fetchVersionsForEntity (entityId: string): Promise { + const { data, error } = await supabase + .from('document_versions') + .select(` + id, + document_id, + page_id, + version_name, + created_at, + created_by, + profiles:created_by (email, full_name) + `) + .or(`page_id.eq.${entityId},document_id.eq.${entityId}`) + .order('created_at', { ascending: false }) + + if (error) { + throw error + } + return (data as unknown as DocumentVersion[]) || [] +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm run test -- tests/unit/db-graph.test.ts` +Expected: all pass (10 existing + 17 new). + +- [ ] **Step 6: Lint + build** + +Run: `npm run lint && npm run build` +Expected: green. + +- [ ] **Step 7: Commit** + +```bash +git add types/index.ts services/graph.ts tests/unit/db-graph.test.ts +git commit -m "feat(graph): page member/invite service layer and workspace bootstrap" +``` + +--- + +### Task 4: Dashboard cutover + +**Files:** +- Modify: `components/dashboard.tsx` (imports :7, state :30-31, fetch :102-125, create :144-152, delete/rename handlers, sort/filter accessor :200-229, navigation :441/:552, shared card fields :549-574, copy strings) +- Test: `tests/unit/dashboard-refetch-on-auth.test.tsx` (mock swap + regex updates) + +**Interfaces:** +- Consumes: `ensureWorkspace`, `fetchWorkspacePages`, `fetchSharedPages`, `fetchPendingPageInvitations`, `createPage`, `updatePageTitle`, `deletePage` (Task 3). +- Produces: dashboard renders owned pages (`Page[]`) and shared pages (`MemberPageItem[]`), navigates to `/page/{id}`. + +- [ ] **Step 1: Write the failing tests** + +In `tests/unit/dashboard-refetch-on-auth.test.tsx`: + +(a) Replace the `vi.mock('@/services/db', ...)` block (lines 30-39) with: +```ts +const ensureWorkspace = vi.fn() +const fetchWorkspacePages = vi.fn() +const fetchSharedPages = vi.fn() +const fetchPageInvites = vi.fn() +const createPage = vi.fn() + +vi.mock('@/services/graph', () => ({ + ensureWorkspace: (...args: any[]) => ensureWorkspace(...args), + fetchWorkspacePages: (...args: any[]) => fetchWorkspacePages(...args), + fetchSharedPages: (...args: any[]) => fetchSharedPages(...args), + fetchPendingPageInvitations: (...args: any[]) => fetchPageInvites(...args), + createPage: (...args: any[]) => createPage(...args), + deletePage: vi.fn(), + updatePageTitle: vi.fn(), +})) +``` + +(b) Remove the old `const createDocument = vi.fn()` line (it is now declared in the new block above). + +(c) In `beforeEach` (lines 64-72), replace the resets with: +```ts + beforeEach(() => { + authCallback = null + ensureWorkspace.mockReset() + fetchWorkspacePages.mockReset() + fetchSharedPages.mockReset() + fetchPageInvites.mockReset() + ensureWorkspace.mockResolvedValue({ id: 'ws-1', owner_id: 'user-1' }) + fetchWorkspacePages.mockResolvedValue([]) + fetchSharedPages.mockResolvedValue([]) + fetchPageInvites.mockResolvedValue([]) + }) +``` + +(d) Rename every remaining `fetchOwned` → `fetchWorkspacePages`, `fetchShared` → `fetchSharedPages`, `fetchInvites` → `fetchPageInvites` throughout the file (the test bodies reference the mocks by their old names). Update the mock-data shapes: `{ id: 'doc-1', title: 'My Doc', owner_id: 'user-1', updated_at: ... }` stays valid for `Page` (same field names), but the "retry" test's error regex must change: +- `expect(await screen.findByText(/couldn't load your documents/i))` → `(/couldn't load your pages/i)` (3 occurrences) +- `expect(screen.queryByText(/welcome to lekhan/i))` → `(/welcome to lekhan/i)` (unchanged) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/dashboard-refetch-on-auth.test.tsx` +Expected: FAIL (dashboard still imports `@/services/db`; mocked `@/services/graph` is never used, and `fetchWorkspacePages` mock is never called). + +- [ ] **Step 3: Implement the dashboard cutover** + +In `components/dashboard.tsx`: + +(a) Line 5 import — replace with: +```ts +import { MemberPageItem, Page } from '@/types' +``` + +(b) Line 7 import — replace with: +```ts +import { ensureWorkspace, fetchWorkspacePages, fetchSharedPages, createPage, deletePage, updatePageTitle, fetchPendingPageInvitations } from '@/services/graph' +``` + +(c) Lines 30-31 state — replace with: +```ts + const [myPages, setMyPages] = useState([]) + const [sharedPages, setSharedPages] = useState([]) +``` + +(d) The `fetchDocuments` callback (lines 102-125) — replace the body with: +```ts + const fetchPages = useCallback(async () => { + const requestId = ++fetchRequestIdRef.current + setLoading(true) + setFetchError(false) + try { + const workspace = await ensureWorkspace(user.id) + const [owned, shared, invites] = await Promise.all([ + fetchWorkspacePages(workspace.id), + fetchSharedPages(user.id), + fetchPendingPageInvitations(user.email) + ]) + if (requestId !== fetchRequestIdRef.current) return + setMyPages(owned) + setSharedPages(shared) + setPendingInvitesCount(invites.length) + } catch (err) { + if (requestId !== fetchRequestIdRef.current) return + console.error('Error fetching pages:', err) + setFetchError(true) + } finally { + if (requestId === fetchRequestIdRef.current) { + setLoading(false) + } + } + }, [user.id, user.email]) +``` + +(e) Replace `fetchDocuments` references: line 128 `fetchDocuments()` → `fetchPages()`; line 138 (auth event) `fetchDocuments()` → `fetchPages()`; line 148 dependency `[fetchDocuments]` → `[fetchPages]`; line 265 ` { + try { + const workspace = await ensureWorkspace(user.id) + const page = await createPage(workspace.id, user.id) + router.push(`/page/${page.id}`) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Failed to create page: ${message}`) + } + } +``` +Replace all three call sites (`onClick={handleCreateDocument}` at :317, :406, :593) with `onClick={handleCreatePage}`. + +(g) `executeDelete` (lines 162-174) — `await deleteDocument(documentToDelete)` → `await deletePage(documentToDelete)`; `setMyDocs(prev => ...)` → `setMyPages(prev => prev.filter(page => page.id !== documentToDelete))`; toast `'Document deleted successfully'` → `'Page deleted successfully'`. + +(h) `handleRenameSubmit` (lines 176-185) — `await updateDocumentTitle(id, newTitle)` → `await updatePageTitle(id, newTitle)`; `setMyDocs(prev => prev.map(doc => ...))` → `setMyPages(prev => prev.map(page => page.id === id ? { ...page, title: newTitle } : page))`; toast copy `'Failed to rename document'` → `'Failed to rename page'`. + +(i) `applyFiltersAndSort` (lines 200-229) — the nested-accessor expressions `doc.documents && doc.documents[dateField]` / `doc.documents && doc.documents[titleField]` become pages-aware. Replace all four occurrences of `(doc.documents && doc.documents[dateField])` → `(doc.documents?.[dateField] ?? doc.pages?.[dateField])`, `(doc.documents && doc.documents[titleField])` → `(doc.documents?.[titleField] ?? doc.pages?.[titleField])` (title appears in two of the four spots). The `documents` accessor is retained for type tolerance; `MemberPageItem` nests under `pages`. + +(j) `filteredMyDocs`/`filteredSharedDocs` (lines 231-232) — replace with: +```ts + const filteredMyPages = useMemo(() => applyFiltersAndSort(myPages, 'updated_at', 'title') as Page[], [applyFiltersAndSort, myPages]) + const filteredSharedPages = useMemo(() => applyFiltersAndSort(sharedPages, 'updated_at', 'title') as MemberPageItem[], [applyFiltersAndSort, sharedPages]) +``` +Then replace all remaining `filteredMyDocs` → `filteredMyPages`, `filteredSharedDocs` → `filteredSharedPages`, `myDocs` → `myPages`, `sharedDocs` → `sharedPages` in the JSX (lines 234-240 scroll effects, 309-310 empty-state conditions, 347-359 header condition, 413-423 section header/empty states, 438-501 cards, 524-585 shared section). + +(k) Card navigation (line 441): `router.push(`/doc/${doc.id}`)` → `router.push(`/page/${page.id}`)`; shared card (line 552): `router.push(`/doc/${item.documents.id}`)` → `router.push(`/page/${item.pages.id}`)`. + +(l) Shared card body (lines 563-571): `{item.documents.title}` → `{item.pages.title}`; `{new Date(item.documents.updated_at).toLocaleDateString()}` → `{new Date(item.pages.updated_at).toLocaleDateString()}`; `{item.role}` stays. + +(m) User-visible copy (replace exact strings): +- `"Couldn't load your documents"` → `"Couldn't load your pages"` +- `"Something went wrong while fetching your documents. Please try again."` → `"Something went wrong while fetching your pages. Please try again."` +- `"Documents"` (section header, line 355) → `"Pages"` +- `"No owned documents yet"` → `"No pages yet"`; `"You haven't created any documents. Use the New button to start collaborating!"` → `"You haven't created any pages. Use the New button to start collaborating!"`; `"No documents match your search query."` → `"No pages match your search query."` +- `"No documents match your current search or filter criteria."` → `"No pages match your current search or filter criteria."` +- `"You haven't created any documents yet. Create your first document to start collaborating with your team!"` → `"You haven't created any pages yet. Create your first page to start collaborating with your team!"` +- `"No shared documents yet"` → `"No shared pages yet"`; `"Documents appear here when you're invited to collaborate."` → `"Pages appear here when you're invited to collaborate."`; `"No shared documents match your search query."` → `"No shared pages match your search query."` +- ConfirmDialog (lines 597-604): title `"Delete Document"` → `"Delete Page"`, description `"...delete this document?..."` → `"...delete this page?..."`. +- `key={item.documents.id}` (line 551) → `key={item.pages.id}`; `item.documents` in the shared card's `onClick` closure stays consistent per (k)/(l). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/unit/dashboard-refetch-on-auth.test.tsx` +Expected: all 6 tests pass. + +- [ ] **Step 5: Lint + build** + +Run: `npm run lint && npm run build` +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add components/dashboard.tsx tests/unit/dashboard-refetch-on-auth.test.tsx +git commit -m "feat(dashboard): cut over to pages and page members" +``` + +--- + +### Task 5: Editor route + workspace cutover + +**Files:** +- Create: `app/page/[id]/page.tsx` +- Modify: `app/doc/[id]/page.tsx` (replace with redirect) +- Modify: `components/editor-workspace.tsx` (import :45, prop rename, role check :284-305, mentionables :238-250, collab hook :340, title save :685-688, VersionHistory/ShareModal props :1041/:1069) +- Test: `tests/unit/editor-formatting.test.tsx` + +**Interfaces:** +- Consumes: `fetchPageDetails`, `fetchPageMemberRole`, `updatePageTitle`, `fetchMentionablePageCollaborators`, `getUserAICredits` (Task 3 / db.ts). +- Produces: `/page/[id]` route (full session/anon logic); `/doc/[id]` server redirect; `EditorWorkspace` prop renamed `documentId` → `pageId`. + +- [ ] **Step 1: Write the failing tests** + +In `tests/unit/editor-formatting.test.tsx`: + +(a) Replace the `vi.mock('@/services/db', ...)` block (lines 34-42) with separate +mocks for the two service modules — `getUserAICredits` is imported from +`@/services/db` by `EditorWorkspace`, so it must be mocked there, NOT in the +graph-service mock: +```ts +vi.mock('@/services/graph', () => ({ + fetchPageDetails: vi.fn().mockResolvedValue({ owner_id: 'test-user', is_public: false }), + fetchPageMemberRole: vi.fn().mockResolvedValue('owner'), + updatePageTitle: vi.fn().mockResolvedValue(true), + fetchMentionablePageCollaborators: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/services/db', () => ({ + getUserAICredits: vi.fn().mockResolvedValue({ plan: 'free', totalAllocated: 50, usedCredits: 0, remainingCredits: 50 }), +})) +``` + +(b) In the render call, `documentId="doc-1"` → `pageId="page-1"`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/editor-formatting.test.tsx` +Expected: FAIL (component still imports `@/services/db`; prop `documentId` still required — `pageId` renders nothing). + +- [ ] **Step 3: Implement the new route** + +Create `app/page/[id]/page.tsx` with exactly: + +```tsx +'use client' + +import { use, useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { supabase } from '@/lib/supabase' +import { fetchPageDetails } from '@/services/graph' +import GlobalLoader from '@/components/global-loader' +import EditorWorkspace from '@/components/editor-workspace' +import { toast } from 'sonner' + +export default function PageRoute({ + params: paramsPromise, +}: { + params: Promise<{ id: string }> +}) { + const params = use(paramsPromise) + const router = useRouter() + const [user, setUser] = useState(null) + const [token, setToken] = useState(null) + const [pageTitle, setPageTitle] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const loadPageAndSession = async () => { + try { + // 1. Get current session and token + const { data: { session } } = await supabase.auth.getSession() + const { error: userError } = await supabase.auth.getUser() + + // If there is an auth error that is NOT just a missing session, it means the token expired or is invalid + if (userError && userError.name !== 'AuthSessionMissingError') { + console.error('Session error (token expired):', userError) + toast.error('Session expired. Please log in again.') + router.push('/login') + return + } + + if (session) { + setUser(session.user) + setToken(session.access_token) + } else { + // Check if page is public + try { + const page = await fetchPageDetails(params.id) + if (page && page.is_public) { + // Mock anonymous user + const randomId = Math.random().toString(36).substring(7) + setUser({ + id: `anon-${randomId}`, + email: 'anonymous@public', + full_name: 'Anonymous Viewer' + }) + setToken('anonymous') + } else { + router.push('/login') + return + } + } catch { + router.push('/login') + return + } + } + + // 2. Fetch page details + const page = await fetchPageDetails(params.id) + setPageTitle(page.title) + } catch (err: unknown) { + console.error('Error loading page:', err) + toast.error('Page not found or access denied') + router.push('/') + } finally { + setLoading(false) + } + } + + loadPageAndSession() + }, [params.id, router]) + + if (loading) { + return + } + + if (!user || !token || !pageTitle) { + return null + } + + return ( + + ) +} +``` + +- [ ] **Step 4: Replace `/doc/[id]` with a redirect** + +Replace the entire content of `app/doc/[id]/page.tsx` with: + +```tsx +import { redirect } from 'next/navigation' + +export default async function DocumentPage({ + params: paramsPromise, +}: { + params: Promise<{ id: string }> +}) { + const params = await paramsPromise + redirect(`/page/${params.id}`) +} +``` + +- [ ] **Step 5: Implement the EditorWorkspace cutover** + +In `components/editor-workspace.tsx`: + +(a) Line 45 import — replace with: +```ts +import { fetchPageDetails, fetchPageMemberRole, updatePageTitle, fetchMentionablePageCollaborators } from '@/services/graph' +import { getUserAICredits } from '@/services/db' +``` + +(b) Props interface (lines 67-76): rename `documentId: string` → `pageId: string`. + +(c) Destructure (line 158): `documentId,` → `pageId,`. + +(d) `loadMentionables` (lines 238-250): `fetchMentionableCollaborators(documentId)` → `fetchMentionablePageCollaborators(pageId)`; deps `[documentId]` → `[pageId]`. + +(e) `checkRole` (lines 284-305): replace with: +```ts + useEffect(() => { + const checkRole = async () => { + try { + const page = await fetchPageDetails(pageId) + if (page && page.owner_id === currentUser.id) { + setIsViewer(false) + return + } + + const role = await fetchPageMemberRole(pageId, currentUser.id) + if (role === 'editor' || role === 'owner') { + setIsViewer(false) + } else { + setIsViewer(true) + } + } catch (err) { + console.error('Error fetching role:', err) + setIsViewer(true) + } + } + checkRole() + }, [pageId, currentUser.id]) +``` + +(f) Collab hook (line 340): `useEditorCollab(documentId, token, collabUser)` → `useEditorCollab(pageId, token, collabUser)`. + +(g) `handleSaveTitle` (lines 685-688): `await updateDocumentTitle(documentId, newTitle)` → `await updatePageTitle(pageId, newTitle)`. + +(h) VersionHistory prop (line 1041): `documentId={documentId}` → `documentId={pageId}`; ShareModal prop (line 1069): `documentId={documentId}` → `documentId={pageId}`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm run test -- tests/unit/editor-formatting.test.tsx` +Expected: PASS. + +- [ ] **Step 7: Lint + build** + +Run: `npm run lint && npm run build` +Expected: green. + +- [ ] **Step 8: Commit** + +```bash +git add app/page/[id]/page.tsx app/doc/[id]/page.tsx components/editor-workspace.tsx tests/unit/editor-formatting.test.tsx +git commit -m "feat(editor): page route with /doc redirect and pages-based workspace" +``` + +--- + +### Task 6: Share modal — page members UI + +**Files:** +- Modify: `components/share-modal.tsx` + +**Interfaces:** +- Consumes: `fetchPageDetails`, `updatePagePublicStatus`, `createPageInvitation`, `fetchPageMembers`, `removePageMember`, `updatePageMemberRole` (Task 3); `fetchPastCollaborators` (db.ts, unchanged). +- Produces: `ShareModal` with props `{ isOpen, onClose, documentId, documentTitle, userId, isOwner }` — public toggle, invite form, member list with role dropdown + remove (owner-only), past-collaborator chips, `/page/{id}` public URL. + +- [ ] **Step 1: Update imports and props** + +(a) Line 6 import — replace with: +```ts +import { fetchPageDetails, updatePagePublicStatus, createPageInvitation, fetchPageMembers, removePageMember, updatePageMemberRole } from '@/services/graph' +import { fetchPastCollaborators } from '@/services/db' +import { PageMember } from '@/types' +``` + +(b) Props interface (lines 9-15) — add `isOwner`: +```ts +interface ShareModalProps { + isOpen: boolean + onClose: () => void + documentId: string + documentTitle: string + userId: string + isOwner: boolean +} +``` + +(c) Destructure (lines 17-23) — add `isOwner`. + +(d) Add state next to `pastCollaborators` (line 29): +```ts + const [members, setMembers] = useState([]) + const [membersLoading, setMembersLoading] = useState(false) +``` + +(e) `fetchDocPublicState` (lines 38-45): `fetchDocumentDetails(documentId)` → `fetchPageDetails(documentId)`. + +(f) `handleInvite` (lines 56-76): `createInvitation(documentId, userId, email, role, token)` → `createPageInvitation(documentId, userId, email, role, token)`; toast `'Invite link generated for ${email}!'` stays. + +(g) `handleTogglePublic` (line 83): `updateDocumentPublicStatus(documentId, nextState)` → `updatePagePublicStatus(documentId, nextState)`. + +(h) Public-link URL (line 151 and 157): `${...}/doc/${documentId}` → `${...}/page/${documentId}`. + +- [ ] **Step 2: Add the member list load + handlers** + +After `fetchCollaborators` (line 54), add: + +```ts + const loadMembers = async () => { + setMembersLoading(true) + try { + const data = await fetchPageMembers(documentId) + setMembers(data) + } catch (err) { + console.error('Error fetching page members:', err) + } finally { + setMembersLoading(false) + } + } + + useEffect(() => { + if (isOpen) { + loadMembers() + } + }, [isOpen, documentId]) + + const handleRemoveMember = async (member: PageMember) => { + try { + await removePageMember(documentId, member.user_id) + setMembers(prev => prev.filter(m => m.user_id !== member.user_id)) + toast.success('Member removed') + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Failed to remove member: ${message}`) + } + } + + const handleRoleChange = async (member: PageMember, role: 'editor' | 'viewer') => { + try { + await updatePageMemberRole(documentId, member.user_id, role) + setMembers(prev => prev.map(m => m.user_id === member.user_id ? { ...m, role } : m)) + toast.success('Role updated') + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + toast.error(`Failed to update role: ${message}`) + } + } +``` + +- [ ] **Step 3: Render the member list** + +Insert this block between the public-link toggle section (ends line 167) and the invite form (line 170): + +```tsx + {/* 1.5 Members */} +
+

+ Members ({members.length}) +

+ {membersLoading ? ( +

Loading members...

+ ) : members.length === 0 ? ( +

No collaborators added yet.

+ ) : ( +
    + {members.map((member) => ( +
  • +
    +
    + {(member.profiles?.full_name || member.profiles?.email || '?').charAt(0)} +
    +
    +
    + {member.profiles?.full_name || member.profiles?.email} +
    +
    + {member.profiles?.email} +
    +
    +
    +
    + {isOwner && member.role !== 'owner' ? ( + handleRoleChange(member, val as 'editor' | 'viewer')} + options={[ + { label: 'Editor', value: 'editor' }, + { label: 'Viewer', value: 'viewer' }, + ]} + triggerClassName='h-7 w-[100px] bg-transparent border border-black/10 dark:border-white/10 rounded-lg text-[10px] font-medium text-on-surface px-2 focus:ring-0' + contentClassName='w-[100px]' + /> + ) : ( + + {member.role === 'owner' ? 'Owner' : member.role} + + )} + {isOwner && member.role !== 'owner' && ( + + )} +
    +
  • + ))} +
+ )} +
+``` + +- [ ] **Step 4: Update the modal heading** + +Line 112: `Share Document` → `Share Page`. + +- [ ] **Step 5: Verify (no direct unit test exists for ShareModal — gate via lint/build + TypeScript)** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run lint && npm run build` +Expected: green. Also run `npm run test -- tests/unit/editor-formatting.test.tsx tests/unit/dashboard-refetch-on-auth.test.tsx` to confirm no regressions from the new `isOwner` prop (EditorWorkspace must pass it — see Step 6). + +- [ ] **Step 6: Pass `isOwner` from EditorWorkspace** + +In `components/editor-workspace.tsx`, the ShareModal usage (lines 1066-1072) becomes: + +```tsx + setIsShareOpen(false)} + documentId={pageId} + documentTitle={title} + userId={currentUser.id} + isOwner={isViewer === false && currentUser.id === (ownerId ?? '')} + /> +``` + +Add state so the owner id is known: at the top of the `checkRole` effect, capture it: + +```ts + const [ownerId, setOwnerId] = useState(null) +``` + +and in `checkRole` set `setOwnerId(page.owner_id)` when the page resolves. If `page` is missing or fetch fails, `ownerId` stays null and `isOwner` is false (safe default — RLS still enforces owner-only mutations). + +- [ ] **Step 7: Lint + build again** + +Run: `npm run lint && npm run build` +Expected: green. + +- [ ] **Step 8: Commit** + +```bash +git add components/share-modal.tsx components/editor-workspace.tsx +git commit -m "feat(share): page member management in share modal" +``` + +--- + +### Task 7: Invitations + invite page cutover + +**Files:** +- Modify: `components/invitations.tsx` +- Modify: `app/invite/[token]/page.tsx` + +**Interfaces:** +- Consumes: `fetchPendingPageInvitations`, `acceptPageInvitation`, `declinePageInvitation`, `fetchPageInvitationDetails` (Task 3); `PageInvitation` type (Task 3). +- Produces: invitation components render `PageInvitation` and navigate to `/page/{id}`. + +- [ ] **Step 1: Cut over `components/invitations.tsx`** + +(a) Line 4 import: `DocumentInvitation` → `PageInvitation`: +```ts +import { PageInvitation } from '@/types' +``` + +(b) Line 5 import: +```ts +import { fetchPendingPageInvitations, acceptPageInvitation, declinePageInvitation } from '@/services/graph' +``` + +(c) State + fetch (lines 22-40): `useState([])`; `fetchPendingPageInvitations(userEmail)`. + +(d) `handleAccept` (lines 42-52): `acceptPageInvitation(invite, userId)`. + +(e) `handleDecline` (line 56): `declinePageInvitation(inviteId)`. + +(f) Render: `invite.documents?.title` → `invite.pages?.title` (lines 85 and 113); `invite.profiles?.full_name` stays (same embed shape). + +- [ ] **Step 2: Cut over `app/invite/[token]/page.tsx`** + +(a) Line 6 import: `DocumentInvitation` → `PageInvitation`. + +(b) Line 7 import: +```ts +import { fetchPageInvitationDetails, acceptPageInvitation, declinePageInvitation } from '@/services/graph' +``` + +(c) State (line 19): `useState(null)`. + +(d) Redirect after accepted (line 42): `router.push(`/page/${invitation.page_id}`)`. + +(e) `handleAccept` (line 66): `router.push(`/page/${invite.page_id}`)`. + +(f) Heading (line 102): `Document Invitation` → `Page Invitation`; subtitle (line 105): `...join a collaborative document workspace` → `...join a collaborative page workspace`; label (line 110): `Document Title` → `Page Title`; `invite.documents?.title` → `invite.pages?.title` (line 113). + +- [ ] **Step 3: Verify** + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run lint && npm run build && npm run test -- tests/unit/dashboard-refetch-on-auth.test.tsx tests/unit/editor-formatting.test.tsx` +Expected: green (no dedicated unit tests exist for these two files). + +- [ ] **Step 4: Commit** + +```bash +git add components/invitations.tsx app/invite/[token]/page.tsx +git commit -m "feat(invites): page invitations across notifications, banner and invite page" +``` + +--- + +### Task 8: Settings cutover + +**Files:** +- Modify: `app/settings/page.tsx` +- Modify: `components/settings-client.tsx` +- Test: `tests/unit/settings-tabs.test.tsx` + +**Interfaces:** +- Consumes: `fetchOwnedPagesWithMembers`, `removePageMember`, `updatePageMemberRole`, `getUserAICredits` (Task 3 / db.ts). +- Produces: `SettingsClient` props renamed `documents`/`setDocuments` → `pages`/`setPages`; Collaborators tab lists owned pages + `page_members` with remove + role change. + +- [ ] **Step 1: Update the failing test** + +In `tests/unit/settings-tabs.test.tsx`, the render call passes `documents={documents} setDocuments={vi.fn()}` → change to `pages={documents} setPages={vi.fn()}`. + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/settings-tabs.test.tsx` +Expected: FAIL (SettingsClient still expects `documents` props → renders nothing, tab buttons missing). + +- [ ] **Step 2: Cut over `app/settings/page.tsx`** + +(a) Line 8 import: `import { fetchOwnedDocumentsWithMembers } from '@/services/db'` → `import { fetchOwnedPagesWithMembers } from '@/services/graph'`. + +(b) State + fetch (lines 13, 29): rename `documents`/`setDocuments` → `pages`/`setPages`; `const docs = await fetchOwnedPagesWithMembers(sessionUser.id)` → `const pages = await fetchOwnedPagesWithMembers(sessionUser.id)`; `setPages(pages)`. + +(c) Render (line 44): ``. + +- [ ] **Step 3: Cut over `components/settings-client.tsx`** + +(a) Lines 8-13 import — replace with: +```ts +import { + getUserAICredits, + type UserAICredits, +} from '@/services/db' +import { removePageMember, updatePageMemberRole, fetchOwnedPagesWithMembers } from '@/services/graph' +``` + +(b) Props (lines 17-26): rename `documents: initialDocuments = []` → `pages: initialPages = []`, `setDocuments: setParentPages` → `setPages: setParentPages`. + +(c) State (line 37): `const [pagesState, setPagesState] = useState(initialPages)`; sync effect (lines 39-41): `setPagesState(initialPages)`. + +(d) `handleRemoveMember` (lines 108-122): +```ts + const handleRemoveMember = async (pageId: string, memberUserId: string) => { + try { + await removePageMember(pageId, memberUserId) + if (user?.id) { + const updatedPages = await fetchOwnedPagesWithMembers(user.id) + if (setParentPages) { + setParentPages(updatedPages) + } + setPagesState(updatedPages) + } + toast.success('Collaborator removed successfully') + } catch { + toast.error('Failed to remove collaborator') + } + } +``` + +(e) Add a role-change handler after `handleRemoveMember`: +```ts + const handleRoleChange = async (pageId: string, memberUserId: string, role: 'editor' | 'viewer') => { + try { + await updatePageMemberRole(pageId, memberUserId, role) + setPagesState(prev => prev.map((page: any) => + page.id === pageId + ? { ...page, page_members: (page.page_members || []).map((m: any) => m.user_id === memberUserId ? { ...m, role } : m) } + : page + )) + toast.success('Role updated successfully') + } catch { + toast.error('Failed to update role') + } + } +``` + +(f) Pagination (lines 124-130): `documentsState` → `pagesState`. + +(g) Collaborators tab JSX (lines 252-340): `documentsState.length` → `pagesState.length`; `paginatedDocuments` → `paginatedPages`; card `key={doc.id}` stays; title `doc.title || 'Untitled Document'` → `doc.title || 'Untitled Page'`; `const members = doc.page_members || []`; empty copy `"You don't own any documents yet."` → `"You don't own any pages yet."`; member list item uses the same `member.profiles` shape (embedded via `fetchOwnedPagesWithMembers`); add a role `CustomSelect` next to the Remove button (only when `member.role !== 'owner'` — owners never appear as members in the embed since page_members insert excludes the owner, but guard anyway): + +```tsx +
  • +
    +
    + {(member.profiles?.full_name || member.profiles?.email || '?').charAt(0)} +
    +
    +
    + {member.profiles?.full_name || member.profiles?.email} +
    +
    + {member.profiles?.email} • {member.role} +
    +
    +
    +
    + handleRoleChange(doc.id, member.user_id, val as 'editor' | 'viewer')} + options={[ + { label: 'Editor', value: 'editor' }, + { label: 'Viewer', value: 'viewer' }, + ]} + triggerClassName="h-7 w-[100px] bg-transparent border border-black/10 dark:border-white/10 rounded-lg text-[10px] font-medium text-on-surface px-2 focus:ring-0" + contentClassName="w-[100px]" + /> + +
    +
  • +``` + +`CustomSelect` is already imported at the top of settings-client? It is NOT (settings-client imports `BYOKSettings`, `PricingMatrix`). Add: `import { CustomSelect } from './ui/custom-select'`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/unit/settings-tabs.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Lint + build** + +Run: `npm run lint && npm run build` +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add app/settings/page.tsx components/settings-client.tsx tests/unit/settings-tabs.test.tsx +git commit -m "feat(settings): collaborators tab backed by pages and page members" +``` + +--- + +### Task 9: Version history + API route cutover + +**Files:** +- Modify: `components/version-history.tsx` (import :9, save insert :87-95, list load :42-51) +- Modify: `app/api/version/route.ts` (role check :104-127, insert :130-138) +- Test: `tests/unit/version-history-reassurance.test.tsx` (mock chain gains `or`) + +**Interfaces:** +- Consumes: `fetchVersionsForEntity` (Task 3). +- Produces: version checkpoints written with `page_id` for pages (RLS pages branch); the list queries `page_id OR document_id`; the API route resolves pages first. + +- [ ] **Step 1: Update the failing test** + +In `tests/unit/version-history-reassurance.test.tsx`, the supabase mock chain (lines 8-16) must gain `or` (and `insert`, `upload` for the save path — not exercised by the retention test, but the chain needs `or` for `fetchVersionsForEntity`): + +```ts +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: vi.fn(() => ({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), + then: vi.fn((cb: any) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)), + })), + }, +})) +``` + +Run: `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" && npm run test -- tests/unit/version-history-reassurance.test.tsx` +Expected: currently PASSES (chain `then` missing is tolerated by the catch); after the change it must still pass — treat as regression guard, not red-green. + +- [ ] **Step 2: Cut over `components/version-history.tsx`** + +(a) Line 9 import: `import { fetchVersions } from '@/services/db'` → `import { fetchVersionsForEntity } from '@/services/graph'`. + +(b) `loadVersions` (lines 42-51): `const data = await fetchVersions(documentId)` → `const data = await fetchVersionsForEntity(documentId)`. + +(c) Save-version insert (lines 87-95): the `document_versions` insert becomes: +```ts + const { error: dbError } = await supabase + .from('document_versions') + .insert({ + id: versionId, + page_id: documentId, + version_name: newVersionName.trim(), + storage_path: `${documentId}/versions/${versionId}.bin`, + created_by: user.id, + }) +``` + +(d) Update the `DocumentVersion` reference in the confirm dialog (line 308): `description={`Are you sure you want to restore "${versionToRestore?.version_name}"? Unsaved changes in the current document will be replaced.`}` → `...Unsaved changes in the current page will be replaced.` (optional copy). + +- [ ] **Step 3: Cut over `app/api/version/route.ts`** + +(a) Replace the role check (lines 104-127) with pages-first resolution: + +```ts + // 1. Verify user role: only owners and editors can create versions. + // Pages are the primary entity; legacy documents fall back for + // unmapped ids (rollback path). + const { data: page } = await supabaseClient + .from('pages') + .select('owner_id') + .eq('id', documentId) + .maybeSingle() + + let isOwner = !!(page && page.owner_id === user.id) + let isEditor = false + + if (!page) { + const { data: doc } = await supabaseClient + .from('documents') + .select('owner_id') + .eq('id', documentId) + .maybeSingle() + + isOwner = !!(doc && doc.owner_id === user.id) + + if (!isOwner && doc) { + const { data: member } = await supabaseClient + .from('document_members') + .select('role') + .eq('document_id', documentId) + .eq('user_id', user.id) + .single() + + isEditor = !!(member && member.role === 'editor') + } + } else if (!isOwner) { + const { data: member } = await supabaseClient + .from('page_members') + .select('role') + .eq('page_id', documentId) + .eq('user_id', user.id) + .single() + + isEditor = !!(member && member.role === 'editor') + } + + if (!isOwner && !isEditor) { + return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }) + } +``` + +(b) Replace the insert (lines 130-138) to write `page_id` for pages: + +```ts + // 2. Create the document_versions record (page_id for pages) + const { data: version, error: dbError } = await supabaseClient + .from('document_versions') + .insert( + page + ? { + page_id: documentId, + version_name: versionName, + created_by: user.id, + } + : { + document_id: documentId, + version_name: versionName, + created_by: user.id, + } + ) + .select() + .single() +``` + +- [ ] **Step 4: Verify** + +Run: `npm run test -- tests/unit/version-history-reassurance.test.tsx && npm run lint && npm run build` +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add components/version-history.tsx app/api/version/route.ts tests/unit/version-history-reassurance.test.tsx +git commit -m "feat(versions): pages-aware checkpoints and API route" +``` + +--- + +### Task 10: Whole-branch verification + live DB matrix + +**Files:** +- Test: full focused run of all touched test files + lint + build +- Controller: Supabase MCP live verification + advisors + handover + +**Interfaces:** +- Consumes: everything from Tasks 1-9. + +- [ ] **Step 1: Full focused test run + gates** + +Run: +```bash +export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH" +npm run test -- tests/unit/server.test.ts tests/unit/db-graph.test.ts tests/unit/graph-index.test.ts tests/unit/dashboard-refetch-on-auth.test.tsx tests/unit/editor-formatting.test.tsx tests/unit/settings-tabs.test.tsx tests/unit/version-history-reassurance.test.tsx tests/unit/db.test.ts tests/unit/db-collaborators.test.ts tests/unit/db-credits-limits.test.ts +npm run lint +npm run build +``` +Expected: all pass, lint clean, build green. (Do NOT run the full `npm run test` — it OOMs on this machine.) + +- [ ] **Step 2: Controller live-verify RLS on the project (Supabase MCP)** + +Using `supabase_execute_sql` with the authenticated simulation pattern (`SET LOCAL ROLE authenticated` + `set_config('request.jwt.claims', '{"sub":""}', true)`), verify against project `hftipkzqbltdkrcjynad`: + +(a) `can_access_page` no longer references document_members: +```sql +SELECT prosrc LIKE '%document_members%' AS still_references FROM pg_proc WHERE proname = 'can_access_page'; +``` +Expected: `still_references = false`. + +(b) Member read / stranger denied / public read on pages: +```sql +-- as owner: 1 +SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claims', '{"sub":""}', true); SELECT count(*) FROM public.pages; +``` +Expected: owner sees their pages; a stranger uid sees 0 (unless public); anon sees only `is_public = true` pages via `select_pages_public` (verify with a known public page from the live data). + +(c) Twin-less page storage access: create a scratch page (as owner), then as that owner simulate storage RLS: +```sql +SELECT public.can_access_document_storage('/main_state.bin', 'select'); +SELECT public.can_access_document_storage('/main_state.bin', 'insert'); +SELECT public.can_access_document_storage('/main_state.bin', 'delete'); +``` +Expected: select/insert true, delete true for owner. Stranger uid: all false. **Then delete the scratch page.** + +(d) Invitee self-insert: insert a pending `page_invitations` row for a scratch page (service role), then as the invitee uid: +```sql +SELECT set_config('request.jwt.claims', '{"sub":"", "email":""}', true); +INSERT INTO public.page_members (page_id, user_id, role) VALUES ('', '', 'viewer'); +``` +Expected: INSERT succeeds (self-insert policy). Then delete the row (service role) and clean the scratch invitation. + +(e) Pending legacy invite conversion: `SELECT count(*) FROM public.page_invitations WHERE status = 'pending';` — count should match the pre-existing pending `document_invitations` rows that had page mappings (compare against `SELECT count(*) FROM public.document_invitations di JOIN public.pages p ON p.source_document_id = di.document_id WHERE di.status = 'pending'` — counts must be equal; invitee/token values copied). + +- [ ] **Step 3: Advisors check** + +Run: `supabase_get_advisors` (security + performance). Expected: same intentional WARNs as P1 (SECURITY DEFINER helpers, auth.uid() guard pattern, pre-existing legacy warnings) — no NEW findings introduced by the new policies. + +- [ ] **Step 4: Final review pass (controller)** + +Run a whole-branch review: `git log --oneline main..HEAD`, verify each of the 10 tasks' commits exists and no stray files; confirm `git status` clean. + +- [ ] **Step 5: Write the SDD handover** + +Create `.superpowers/sdd/2026-08-15-p2-client-cutover/HANDOVER.md` (mirror the P1 handover structure: state, what was built, live verification results, gotchas, deferred items — e.g. `profiles.plan`/credits retirement rides on the billing plan; `fetchPastCollaborators` still reads legacy tables so it is empty for twin-less pages; the `page_members` stale-vs-live-grant problem is gone by construction because the legacy app no longer writes `document_members`). + +- [ ] **Step 6: Commit** + +```bash +git add .superpowers/sdd/2026-08-15-p2-client-cutover/HANDOVER.md +git commit -m "docs(handover): P2 client cutover handover" +``` +(.superpowers is git-ignored — if `git add` warns, note that the handover is stored locally for the next session like P1's.) diff --git a/docs/superpowers/specs/2026-08-13-p2-client-cutover-design.md b/docs/superpowers/specs/2026-08-13-p2-client-cutover-design.md new file mode 100644 index 0000000..9a6c48c --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-p2-client-cutover-design.md @@ -0,0 +1,160 @@ +# P2 Design: Client Cutover onto the Pages Graph + +**Date:** 2026-08-13 +**Status:** Approved (design) +**Plan:** `docs/superpowers/plans/2026-08-15-p2-client-cutover.md` (follows writing-plans) +**Supersedes handover:** `.superpowers/sdd/2026-08-12-p1-pages-graph-foundation/HANDOVER.md` "Next steps #2" (P2 client cutover, `page_members` UI, parked role-fallback fix). + +## 1. Goal + +P1 (pages-graph foundation) shipped the `workspaces`/`pages`/`page_members`/`page_links`/`page_tags` schema, the graph index service, and pages-first server auth — but the client still reads and writes the legacy `documents`/`document_members`/`document_invitations`/`document_versions` tables via `services/db.ts`. P2 moves the entire client onto the pages data model so that: + +- The app's data layer is the knowledge graph (pages as nodes), matching the strategy spec's substrate. +- The parked auth/RLS inconsistency (role fallback outranks page RLS) is resolved before the client starts writing through pages. +- New pages (which have no `documents` twin) work end-to-end: editor sync, storage, version history, sharing. + +## 2. Decisions (locked) + +1. **Minimal cutover scope.** Dashboard, editor, share modal, invitations, settings, and version history move onto pages + `page_members` + `page_invitations`. Only the legacy `createDocument` doc-cap (5-doc free limit) is retired with page creation. AI credits (`profiles.plan`, `used_credits`) and their enforcement are untouched — their retirement rides on the separate billing (Stripe/Razorpay) and AI-provider-registry H0 plans. +2. **New `page_invitations` table** mirroring the legacy `document_invitations` shape (email + token + status + role), so the existing invite/accept UX carries over unchanged. +3. **Page-only authority.** For pages, access = owner OR `page_members` OR public. All `document_members`/`source_document_id` branches are dropped from both RLS helpers and the sync server's role verification, so auth and RLS agree exactly. +4. **Editor URL becomes `/page/[id]`**, with `/doc/[id]` kept as a redirect (existing bookmarks, share links, and invite links keep working). +5. **Legacy tables stay in the database** as an inert rollback path; the documents-mirror write-throughs in `services/graph.ts` (`deletePage`, `updatePagePublicStatus`) are retained per the minimal-scope decision. +6. **No workspace UI in P2.** Every user has exactly one personal workspace (`workspaces.UNIQUE(owner_id)`); the client silently fetch-or-creates it (`ensureWorkspace`) before first page creation. Dashboard stays a flat page list. + +## 3. Out of Scope (deferred) + +- Global search, markdown import/export, Obsidian importer, i18n, AI provider registry, real billing, USD/INR pricing, docs site (remaining H0 items — each gets its own plan). +- Graph view + backlinks pane, daily notes, publish, comments, templates, desktop/mobile, Plugins API (H1). +- Retiring `profiles.plan`/`used_credits`, the credits ledger, and plan-limit enforcement (billing plan). +- Renaming `document_versions` to `page_versions` (table name stays; see Section 5 — only a `page_id` column is added). +- Workspace switcher, team workspaces, workspace members (H2). + +## 4. Architecture + +### 4.1 Data flow after cutover (pages-first, mirrors retained) + +- **Read path (client):** all lists/detail queries hit `pages`, `page_members`, `page_invitations` through the anon client (`lib/supabase.ts`) under RLS. `documents`/`document_members`/`document_invitations` are no longer queried by the UI. +- **Write path (client):** page CRUD, member management, invites, public toggle via `services/graph.ts`. `updatePagePublicStatus` and `deletePage` keep their legacy documents-mirror writes (harmless, keeps the rollback path warm). +- **Sync path (server):** unchanged from P1 — pages-first in `verifyUserRole`/`getEntityOwner`; `saveDocumentState` indexes via `sync_page_graph`; storage uploads to `{entityId}/main_state.bin` in the `documents` bucket (page ids remain the storage keys). `verifyUserRole`'s page branch now reads **only** `page_members` (fix below). +- **Version history:** `document_versions` gains a nullable `page_id` FK; policies check pages first, documents fallback. + +### 4.2 RLS/authority model after cutover (page = owner | page_members | public) + +| Surface | Rule | +|---|---| +| `pages` select | `can_access_page(id)`: owner OR `is_public` OR page_members (documents branch removed) + anon `select_pages_public` | +| `pages` insert | owner AND owns the workspace (existing hardened policy) | +| `pages` update / delete | owner only | +| `page_members` select | `can_access_page(page_id)` | +| `page_members` insert | owner of page OR invitee self-insert with a matching pending `page_invitation` | +| `page_members` update | owner of page (NEW — role changes) | +| `page_members` delete | owner of page (existing) | +| `page_invitations` select | owner OR invitee (email match) | +| `page_invitations` insert | owner of page | +| `page_invitations` update | owner OR invitee (accept/decline) | +| `page_invitations` delete | owner of page | +| `page_links` select | from-only `can_access_page(from_page_id)` (existing) | +| `page_tags` select | `can_access_page(page_id)` (existing) | +| `document_versions` | pages-first: owner/member select; owner/editor insert; owner delete — documents fallback for legacy rows | +| Storage (`can_access_document_storage`) | select: doc owner/public/member OR page owner/public/page_members; insert/update: doc owner/editor OR page owner/page_members editor; delete: doc owner OR page owner | + +### 4.3 Role-fallback fix (parked item, resolved) + +`server/auth.js` `verifyUserRole` for a page entity: + +- owner short-circuit (unchanged); +- `page_members` row → its role; +- **no live `document_members` fallback** (dropped — `page_members` is the sole page authority; matches `can_access_page`); +- authenticated non-member on a public page → `'viewer'` (fixes the WS-denial divergence where anon could read but a signed-in stranger could not connect); +- anonymous → `'viewer'` only if public (unchanged). + +`getEntityOwner` remains pages-first with documents fallback so unmapped legacy ids still resolve during the transition. `getDocumentOwnerPlanLimit` unchanged (WS concurrency caps stay; plan-based limits are a billing-plan concern). + +### 4.4 In-flight legacy invites + +The foundation migration converts **pending** `document_invitations` rows whose `document_id` maps to a page (`pages.source_document_id = document_id`) into `page_invitations` rows (same role, token, invitee, inviter; status pending). Accepted/declined rows are left untouched. Legacy invite tokens in the wild therefore keep working through the page flow. + +## 5. Components + +### 5.1 Foundation (migration `20260815000000_page_only_authority.sql` + `server/auth.js`) + +1. Rewrite `can_access_page`: owner OR `is_public` OR `page_members` (drop the `source_document_id`/`document_members` branch). +2. Restore the pages branch in `can_access_document_storage` (dropped by P1's `20260814000002` — without it, twin-less pages cannot touch the storage bucket, breaking editor persistence and version downloads): select = page owner OR public OR page_members; insert/update = page owner OR page_members role `editor`; delete = page owner. Documents branch unchanged. +3. `document_versions`: add `page_id UUID NULL REFERENCES pages(id) ON DELETE CASCADE`; rewrite `select_versions`/`insert_versions`/`delete_versions` to grant via pages first (select: owner/member; insert: owner/editor member; delete: owner), documents fallback. +4. New `page_invitations` table: `page_id` FK pages CASCADE, `inviter_id` FK profiles, `invitee_email`, `role member_role`, `token uuid default gen_random_uuid()`, `status invitation_status`, `created_at`; RLS per Section 4.2. +5. `page_members`: add owner-only update policy; extend insert policy with the invitee self-insert clause (pending `page_invitation` for their email). +6. Backfill: pending `document_invitations` → `page_invitations` via `pages.source_document_id` mapping. +7. `server/auth.js` `verifyUserRole`: page-only authority + public-page viewer for authenticated strangers (Section 4.3). `server/index.js` unchanged. + +### 5.2 Client service layer (`services/graph.ts`, `types/index.ts`) + +New exported functions (all through the `lib/supabase.ts` anon client, throwing on error, matching the existing style): + +- `ensureWorkspace(userId)` → fetch the user's workspace; insert `{owner_id: userId}` if missing; return it. +- `fetchSharedPages(userId)` → `page_members` joined with pages (role + page embed) for the user. +- `fetchPageMemberRole(pageId, userId)` → `'owner' | 'editor' | 'viewer' | null`. +- `fetchPageMembers(pageId)` → members with profile embed (avatar, full_name, email). +- `removePageMember(pageId, userId)`. +- `updatePageMemberRole(pageId, userId, role)`. +- `createPageInvitation(pageId, inviterId, inviteeEmail, role, token)` with pending-invite counting for the collaborator limit (mirroring the legacy `createInvitation` behavior; limit source remains `profiles.plan` until billing lands). +- `fetchPendingPageInvitations(email)` → with page title embed. +- `acceptPageInvitation(invite, userId)` → insert `page_members` + mark accepted (same transaction semantics as legacy: sequential writes, invitee self-insert relies on the new policy). +- `declinePageInvitation(inviteId)`. +- `fetchPageInvitationDetails(token)` → with page title + inviter profile embed. +- `fetchMentionablePageCollaborators(pageId)` → owner + page_members (role `editor`) with profile embed, for @mentions. +- `fetchOwnedPagesWithMembers(userId)` → owned pages + embedded `page_members` with profiles (settings tab). + +Types: `PageMember` (`id`, `page_id`, `user_id`, `role: 'owner' | 'editor' | 'viewer'`, `created_at`, `profiles?`), `PageInvitation` (mirror `DocumentInvitation` with `page_id`), and add `workspace_id` to `PageLink`. `services/db.ts` keeps only the still-used helpers (AI credits + plan limits); document CRUD/collab functions stay in the file but are no longer imported by the UI. + +### 5.3 Dashboard (`components/dashboard.tsx`) + +- "My pages": `ensureWorkspace` → `fetchWorkspacePages(workspace.id)`; rename via `updatePageTitle`; delete via `deletePage`; navigate to `/page/${id}`. +- "Shared": `fetchSharedPages`. +- Create: `createPage(workspace.id, userId)` — no doc-cap check (retired). +- Invitations badge + banner: `fetchPendingPageInvitations`. + +### 5.4 Editor (`app/page/[id]/page.tsx` + redirect, `components/editor-workspace.tsx`) + +- New route `app/page/[id]/page.tsx` holds ALL logic (session/anon gate identical to `/doc/[id]` but via `fetchPageDetails`/`select_pages_public`); `app/doc/[id]/page.tsx` becomes a thin server component calling `redirect('/page/' + id)` from `next/navigation` — the anon public-viewer path lives entirely in the new route, so any visitor (signed in, signed out, public-page anon) is redirected first. +- Workspace loads `fetchPageDetails`, `fetchPageMemberRole` (owner → editor-capable; viewer → read-only toolbar, editable=false), `updatePageTitle` on title save, `fetchMentionablePageCollaborators` for @mentions. +- Share button opens the pages-based ShareModal. Viewer/anon gating unchanged in behavior. + +### 5.5 Share modal + invitations + invite page + +- `share-modal.tsx`: public-link toggle via `updatePagePublicStatus`; invite form via `createPageInvitation`; **new member list section** (avatar, name, remove button) via `fetchPageMembers`/`removePageMember` — this is the `page_members` UI. The role dropdown (`updatePageMemberRole`) appears only when the **page owner** opens the modal (the owner-only update policy is the RLS source of truth; the UI just hides the control from non-owners). +- `invitations.tsx`: pending page invites (notification dropdown + dashboard banner). +- `app/invite/[token]/page.tsx`: page-invite variant (`fetchPageInvitationDetails` → accept/decline via page functions; cookie flow unchanged). + +### 5.6 Settings (`app/settings/page.tsx`, `components/settings-client.tsx`) + +- Collaborators & Access tab: owned pages via `fetchOwnedPagesWithMembers`; remove member and change role via page functions. (Legacy "users tab" list of docs is replaced by pages.) + +### 5.7 Version history (`components/version-history.tsx`, `app/api/version/route.ts`) + +- Component: list via `document_versions` select (pages-aware RLS grants it), storage download works via the restored pages branch of `can_access_document_storage`. +- API route: owner/editor checks resolve pages first (owner or `page_members` role `editor`), documents fallback for unmapped ids; insert `document_versions` rows with `page_id` for pages. + +## 6. Error Handling + +- All service functions throw on Supabase errors (existing style); components keep their current toast/console patterns. +- `ensureWorkspace` race: `insert_workspaces` allows owner self-insert; a unique-violation on concurrent create is swallowed and the existing workspace fetched (idempotent). +- Invitee self-insert: if `acceptPageInvitation` hits a policy denial (e.g., invitation already consumed), surface the existing "invitation no longer valid" handling. +- Anon public viewer: unchanged mock-user path (`anon-`, token `'anonymous'`). + +## 7. Verification + +Per-task gates (the P1 lesson applies: vitest+eslint never type-check — **`next build` is a gate on every task that touches TS**): + +- `export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH"` (npm not on PATH) +- Focused vitest files only — the full suite OOMs/hangs on this machine +- `npm run lint` +- `npm run build` (next build) +- Migrations applied to the live project (`hftipkzqbltdkrcjynad`) via Supabase MCP `apply_migration`, then live RLS verification (membership matrix, invitee self-insert, twin-less page storage access, public page anon/stranger reads, pending-invite conversion) +- `node --check` on modified server files + +## 8. Branching & Delivery + +- Branch `p2-client-cutover` from `main` (created; `main` currently at `d8679ea`). +- Land via the repo's GitHub squash-merge PR pattern ("…(#N)"); `editor-enhancements` branch (fully merged) can be deleted locally. +- SDD workspace: `.superpowers/sdd/2026-08-15-p2-client-cutover/` (ledger + task briefs/reports, git-ignored). diff --git a/server/auth.js b/server/auth.js index 36e0447..cb42c7c 100644 --- a/server/auth.js +++ b/server/auth.js @@ -84,23 +84,13 @@ async function verifyUserRole(supabase, entityId, token) { return member.role } - // Temporary authority during legacy cutover: mapped pages inherit live - // document_members grants (the one-time backfill goes stale after later - // grants/revocations made through the legacy app). The page RLS helper - // (can_access_page) already evaluates the same authority. - const { data: page } = await supabase - .from('pages') - .select('source_document_id') - .eq('id', entityId) - .maybeSingle() - if (page && page.source_document_id) { - const { data: legacyMember } = await supabase - .from('document_members') - .select('role') - .eq('document_id', page.source_document_id) - .eq('user_id', user.id) - .single() - return legacyMember ? legacyMember.role : null + // Page-only authority (P2): page_members is the sole membership source + // for pages — the legacy document_members fallback was removed so the + // sync server's role verdict always matches page RLS (can_access_page). + // Authenticated non-members get read-only access to public pages, + // matching RLS (previously they were denied while anon could read). + if (entity.is_public) { + return 'viewer' } return null diff --git a/services/graph.ts b/services/graph.ts index e960e8c..5474d99 100644 --- a/services/graph.ts +++ b/services/graph.ts @@ -1,5 +1,14 @@ import { supabase } from '@/lib/supabase' -import { Backlink, Page, PageLink, PageTag, Workspace } from '@/types' +import { Backlink, DocumentVersion, MemberPageItem, MemberRole, Page, PageInvitation, PageInvitationProjection, PageLink, PageMember, PageTag, Workspace } from '@/types' +import { getPlanCollaboratorLimit, getUserAICredits } from '@/services/db' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const normalizeInvitation = (row: PageInvitationProjection): PageInvitation => ({ + ...row, + pages: Array.isArray(row.pages) ? row.pages[0] : row.pages, + profiles: Array.isArray(row.profiles) ? row.profiles[0] : row.profiles, +}) export async function fetchWorkspaces (userId: string): Promise { const { data, error } = await supabase @@ -163,4 +172,361 @@ export async function fetchWorkspaceGraph (workspaceId: string): Promise<{ pages pages: (pagesResult.data as Page[]) || [], links: (linksResult.data as PageLink[]) || [], } +} + +export async function ensureWorkspace (userId: string): Promise { + const { data: existing, error: fetchError } = await supabase + .from('workspaces') + .select('*') + .eq('owner_id', userId) + .maybeSingle() + + if (fetchError) { + throw fetchError + } + if (existing) { + return existing as Workspace + } + + const { data, error } = await supabase + .from('workspaces') + .insert({ owner_id: userId }) + .select() + .single() + + if (error && error.code !== '23505') { + throw error + } + if (data) { + return data as Workspace + } + + // 23505: another tab created the workspace first — fetch it. + const { data: retry, error: retryError } = await supabase + .from('workspaces') + .select('*') + .eq('owner_id', userId) + .single() + + if (retryError) { + throw retryError + } + return retry as Workspace +} + +export async function fetchSharedPages (userId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('role, pages (*)') + .eq('user_id', userId) + + if (error) { + throw error + } + return (data as unknown as MemberPageItem[]) || [] +} + +export async function fetchPageMemberRole (pageId: string, userId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('role') + .eq('page_id', pageId) + .eq('user_id', userId) + .single() + + if (error) { + return null + } + return data ? (data.role as MemberRole) : null +} + +export async function fetchPageMembers (pageId: string): Promise { + const { data, error } = await supabase + .from('page_members') + .select('*, profiles:user_id (id, email, full_name, avatar_url)') + .eq('page_id', pageId) + .order('created_at', { ascending: true }) + + if (error) { + throw error + } + return (data as unknown as PageMember[]) || [] +} + +export async function removePageMember (pageId: string, userId: string): Promise { + const { error } = await supabase + .from('page_members') + .delete() + .eq('page_id', pageId) + .eq('user_id', userId) + + if (error) { + throw error + } +} + +export async function updatePageMemberRole (pageId: string, userId: string, role: 'editor' | 'viewer'): Promise { + const { error } = await supabase + .from('page_members') + .update({ role }) + .eq('page_id', pageId) + .eq('user_id', userId) + + if (error) { + throw error + } +} + +export async function createPageInvitation ( + pageId: string, + inviterId: string, + inviteeEmail: string, + role: 'editor' | 'viewer', + token: string +): Promise { + const pageDetails = await fetchPageDetails(pageId) + const ownerCredits = await getUserAICredits(pageDetails.owner_id) + const allowedLimit = getPlanCollaboratorLimit(ownerCredits.plan) + + const { count: memberCount, error: memberError } = await supabase + .from('page_members') + .select('*', { count: 'exact', head: true }) + .eq('page_id', pageId) + if (memberError) { + throw memberError + } + + const { count: inviteCount, error: inviteError } = await supabase + .from('page_invitations') + .select('*', { count: 'exact', head: true }) + .eq('page_id', pageId) + .eq('status', 'pending') + if (inviteError) { + throw inviteError + } + + const totalCount = (memberCount || 0) + (inviteCount || 0) + + if (totalCount >= allowedLimit) { + throw new Error(`Collaborator limit reached for page owner's ${ownerCredits.plan.toUpperCase()} plan (max ${allowedLimit}). Upgrade plan to add more collaborators.`) + } + + const { error } = await supabase + .from('page_invitations') + .insert({ + page_id: pageId, + inviter_id: inviterId, + invitee_email: inviteeEmail, + role, + token, + status: 'pending', + }) + + if (error) { + throw error + } +} + +export async function fetchPendingPageInvitations (email: string): Promise { + const { data, error } = await supabase + .from('page_invitations') + .select(` + id, + page_id, + inviter_id, + invitee_email, + role, + token, + status, + created_at, + pages (title), + profiles:inviter_id (email, full_name) + `) + .eq('invitee_email', email) + .eq('status', 'pending') + + if (error) { + throw error + } + return (data as PageInvitationProjection[]).map(normalizeInvitation) || [] +} + +export async function acceptPageInvitation (invite: PageInvitation, userId: string): Promise { + if (invite.status !== 'pending') { + throw new Error('This invitation is no longer available') + } + + const pageDetails = await fetchPageDetails(invite.page_id) + const ownerCredits = await getUserAICredits(pageDetails.owner_id) + const allowedLimit = getPlanCollaboratorLimit(ownerCredits.plan) + + const { count: memberCount, error: memberError } = await supabase + .from('page_members') + .select('*', { count: 'exact', head: true }) + .eq('page_id', invite.page_id) + if (memberError) { + throw memberError + } + + // Count other pending invites, excluding the one being accepted, so the + // invitee's own pending row is not double-counted against the limit. + const { count: inviteCount, error: inviteError } = await supabase + .from('page_invitations') + .select('*', { count: 'exact', head: true }) + .eq('page_id', invite.page_id) + .eq('status', 'pending') + .neq('id', invite.id) + if (inviteError) { + throw inviteError + } + + if ((memberCount || 0) + (inviteCount || 0) >= allowedLimit) { + throw new Error(`Collaborator limit reached for this page's owner (${ownerCredits.plan.toUpperCase()} plan, max ${allowedLimit}).`) + } + + const { error: memberError2 } = await supabase + .from('page_members') + .insert({ + page_id: invite.page_id, + user_id: userId, + role: invite.role, + }) + + if (memberError2 && !memberError2.message.includes('duplicate key')) { + throw memberError2 + } + + const { error: inviteError2 } = await supabase + .from('page_invitations') + .update({ status: 'accepted' }) + .eq('id', invite.id) + + if (inviteError2) { + throw inviteError2 + } +} + +export async function declinePageInvitation (inviteId: string): Promise { + const { error } = await supabase + .from('page_invitations') + .update({ status: 'declined' }) + .eq('id', inviteId) + + if (error) { + throw error + } +} + +export async function fetchPageInvitationDetails (token: string): Promise { + const { data, error } = await supabase + .from('page_invitations') + .select(` + id, + page_id, + inviter_id, + invitee_email, + role, + token, + status, + created_at, + pages (title), + profiles:inviter_id (email, full_name) + `) + .eq('token', token) + .single() + + if (error) { + throw error + } + return normalizeInvitation(data as PageInvitationProjection) +} + +export async function fetchMentionablePageCollaborators (pageId: string): Promise> { + const { data: pageData, error: pageError } = await supabase + .from('pages') + .select('owner_id, profiles:owner_id (id, email, full_name, avatar_url)') + .eq('id', pageId) + .single() + + if (pageError) throw pageError + + const { data: memberData, error: memberError } = await supabase + .from('page_members') + .select('role, profiles:user_id (id, email, full_name, avatar_url)') + .eq('page_id', pageId) + .in('role', ['editor']) + + if (memberError) throw memberError + + const collaboratorsMap = new Map() + + const ownerProfile = pageData?.profiles as unknown as { id: string; email: string; full_name?: string; avatar_url?: string } + if (ownerProfile && ownerProfile.id) { + collaboratorsMap.set(ownerProfile.id, { + id: ownerProfile.id, + email: ownerProfile.email, + full_name: ownerProfile.full_name || ownerProfile.email, + avatar_url: ownerProfile.avatar_url, + }) + } + + for (const m of (memberData || [])) { + const profile = m.profiles as unknown as { id: string; email: string; full_name?: string; avatar_url?: string } + if (profile && profile.id && !collaboratorsMap.has(profile.id)) { + collaboratorsMap.set(profile.id, { + id: profile.id, + email: profile.email, + full_name: profile.full_name || profile.email, + avatar_url: profile.avatar_url, + }) + } + } + + return Array.from(collaboratorsMap.values()) +} + +export async function fetchOwnedPagesWithMembers (userId: string): Promise<(Page & { page_members: PageMember[] })[]> { + const { data, error } = await supabase + .from('pages') + .select(` + *, + page_members ( + id, + user_id, + role, + profiles:user_id (email, full_name) + ) + `) + .eq('owner_id', userId) + .order('updated_at', { ascending: false }) + + if (error) { + throw error + } + return (data || []) as (Page & { page_members: PageMember[] })[] +} + +export async function fetchVersionsForEntity (entityId: string): Promise { + if (!UUID_PATTERN.test(entityId)) { + return [] + } + + const { data, error } = await supabase + .from('document_versions') + .select(` + id, + document_id, + page_id, + version_name, + created_at, + created_by, + profiles:created_by (email, full_name) + `) + .or(`page_id.eq.${entityId},document_id.eq.${entityId}`) + .order('created_at', { ascending: false }) + + if (error) { + throw error + } + return (data as unknown as DocumentVersion[]) || [] } \ No newline at end of file diff --git a/supabase/migrations/20260815000000_page_only_authority.sql b/supabase/migrations/20260815000000_page_only_authority.sql new file mode 100644 index 0000000..3ff1154 --- /dev/null +++ b/supabase/migrations/20260815000000_page_only_authority.sql @@ -0,0 +1,280 @@ +-- P2 page-only authority: pages are governed by owner/page_members/public only. +-- Legacy document_members no longer grant anything on pages (client cutover). +-- Also: page_invitations table, pages-aware document_versions, storage pages branch. + +-- 1. can_access_page: owner OR is_public OR page_members (source_document_id/document_members branch REMOVED) +CREATE OR REPLACE FUNCTION public.can_access_page(target_page_id uuid) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid; +BEGIN + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RETURN false; + END IF; + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = target_page_id + AND ( + p.owner_id = v_uid + OR p.is_public = true + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid + ) + ) + ); +END; +$$; + +-- 2. can_access_document_storage: restore the pages branch (dropped in +-- 20260814000002), keeping the documents branch for legacy objects. +-- Resolve the entity id from the first path segment, then dispatch on whether +-- a pages row exists for it. Select: owner/public/page_members (pages) or +-- owner/public/document_members (documents). Insert/update: owner or editor +-- member. Delete: owner only. +CREATE OR REPLACE FUNCTION public.can_access_document_storage( + object_name text, + action text +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + entity_id_text text; + v_uid uuid; + v_is_page boolean; +BEGIN + v_uid := auth.uid(); + IF v_uid IS NULL THEN + RETURN false; + END IF; + + entity_id_text := (storage.foldername(object_name))[1]; + IF entity_id_text IS NULL OR entity_id_text = '' THEN + entity_id_text := split_part(object_name, '/', 1); + END IF; + + v_is_page := EXISTS ( + SELECT 1 FROM public.pages p WHERE p.id::text = entity_id_text + ); + + IF v_is_page THEN + IF action = 'select' THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text + AND ( + p.owner_id = v_uid + OR p.is_public = true + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid + ) + ) + ); + ELSIF action IN ('insert', 'update') THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text + AND ( + p.owner_id = v_uid + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = v_uid AND m.role = 'editor' + ) + ) + ); + ELSIF action = 'delete' THEN + RETURN EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id::text = entity_id_text AND p.owner_id = v_uid + ); + END IF; + ELSE + IF action = 'select' THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text + AND ( + d.owner_id = v_uid + OR d.is_public = true + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = v_uid + ) + ) + ); + ELSIF action IN ('insert', 'update') THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text + AND ( + d.owner_id = v_uid + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = v_uid AND m.role = 'editor' + ) + ) + ); + ELSIF action = 'delete' THEN + RETURN EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id::text = entity_id_text AND d.owner_id = v_uid + ); + END IF; + END IF; + + RETURN false; +END; +$$; + +-- 3. document_versions: nullable page_id FK (twin-less pages can hold versions); +-- document_id stays NOT NULL for legacy rows written before the cutover. +ALTER TABLE public.document_versions + ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES public.pages(id) ON DELETE CASCADE; + +DROP POLICY IF EXISTS select_versions ON public.document_versions; +CREATE POLICY select_versions ON public.document_versions +FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id + AND ( + p.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = auth.uid() + ) + ) + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id + AND ( + d.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = auth.uid() + ) + ) + ) +); + +DROP POLICY IF EXISTS insert_versions ON public.document_versions; +CREATE POLICY insert_versions ON public.document_versions +FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id + AND ( + p.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = p.id AND m.user_id = auth.uid() AND m.role = 'editor' + ) + ) + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id + AND ( + d.owner_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.document_members m + WHERE m.document_id = d.id AND m.user_id = auth.uid() AND m.role = 'editor' + ) + ) + ) +); + +DROP POLICY IF EXISTS delete_versions ON public.document_versions; +CREATE POLICY delete_versions ON public.document_versions +FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM public.pages p + WHERE p.id = page_id AND p.owner_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM public.documents d + WHERE d.id = document_id AND d.owner_id = auth.uid() + ) +); + +-- 4. page_invitations: page-level invite/accept flow (mirror of document_invitations) +CREATE TABLE IF NOT EXISTS public.page_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + page_id UUID NOT NULL REFERENCES public.pages(id) ON DELETE CASCADE, + inviter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + invitee_email TEXT NOT NULL, + role member_role NOT NULL, + token UUID NOT NULL DEFAULT gen_random_uuid(), + status invitation_status DEFAULT 'pending' NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL +); + +ALTER TABLE public.page_invitations ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS select_page_invitations ON public.page_invitations; +CREATE POLICY select_page_invitations ON public.page_invitations +FOR SELECT TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) +); + +DROP POLICY IF EXISTS insert_page_invitations ON public.page_invitations; +CREATE POLICY insert_page_invitations ON public.page_invitations +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +DROP POLICY IF EXISTS update_page_invitations ON public.page_invitations; +CREATE POLICY update_page_invitations ON public.page_invitations +FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR inviter_id = auth.uid() + OR invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) +); + +DROP POLICY IF EXISTS delete_page_invitations ON public.page_invitations; +CREATE POLICY delete_page_invitations ON public.page_invitations +FOR DELETE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +-- 5. page_members: owner-only role-change policy; insert extended for invitee +-- self-insert on acceptance of a pending page invitation (mirrors legacy). +DROP POLICY IF EXISTS insert_page_members ON public.page_members; +CREATE POLICY insert_page_members ON public.page_members +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR ( + user_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.page_invitations pi + WHERE pi.page_id = page_id + AND pi.invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) + AND pi.status = 'pending' + ) + ) +); + +DROP POLICY IF EXISTS update_page_members ON public.page_members; +CREATE POLICY update_page_members ON public.page_members +FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) +); + +-- 6. Convert pending legacy invites so in-flight invite links keep working. +INSERT INTO public.page_invitations (page_id, inviter_id, invitee_email, role, token, status, created_at) +SELECT p.id, di.inviter_id, di.invitee_email, di.role, di.token, di.status, di.created_at +FROM public.document_invitations di +JOIN public.pages p ON p.source_document_id = di.document_id +WHERE di.status = 'pending' +ON CONFLICT DO NOTHING; diff --git a/supabase/migrations/20260815000001_rls_hardening.sql b/supabase/migrations/20260815000001_rls_hardening.sql new file mode 100644 index 0000000..8d05837 --- /dev/null +++ b/supabase/migrations/20260815000001_rls_hardening.sql @@ -0,0 +1,39 @@ +-- P2 RLS hardening (approved review follow-up): close two invitee escalation +-- paths in the page invitation flow. + +-- 1. insert_page_members invitee self-insert: bind the inserted role to the +-- pending invitation's role so a viewer-invitee cannot self-insert as owner. +DROP POLICY IF EXISTS insert_page_members ON public.page_members; +CREATE POLICY insert_page_members ON public.page_members +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR ( + user_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.page_invitations pi + WHERE pi.page_id = page_id + AND pi.invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) + AND pi.status = 'pending' + AND pi.role = role + ) + ) +); + +-- 2. update_page_invitations split: owner/inviter keep full control; the +-- invitee may only flip status to accepted/declined (cannot pivot page_id, +-- change the role, or keep the invite pending while editing it). The accept +-- flow inserts the page_members row while the invite is still pending, then +-- flips status — this ordering is preserved. +DROP POLICY IF EXISTS update_page_invitations ON public.page_invitations; +CREATE POLICY update_page_invitations_managed ON public.page_invitations +FOR UPDATE TO authenticated USING ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR inviter_id = auth.uid() +); + +CREATE POLICY update_page_invitations_invitee ON public.page_invitations +FOR UPDATE TO authenticated USING ( + invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) +) WITH CHECK ( + status IN ('accepted', 'declined') +); diff --git a/supabase/migrations/20260815000002_rls_hardening_fix.sql b/supabase/migrations/20260815000002_rls_hardening_fix.sql new file mode 100644 index 0000000..2e5677e --- /dev/null +++ b/supabase/migrations/20260815000002_rls_hardening_fix.sql @@ -0,0 +1,21 @@ +-- P2 RLS hardening fix: qualify outer-column references in the invitee +-- self-insert arm. Unqualified `page_id`/`role` inside the EXISTS subquery +-- bind to the inner page_invitations row (innermost scope wins), making the +-- checks always-true. Qualifying with `page_members.` forces outer binding +-- (same pattern the legacy insert_members policy uses). + +DROP POLICY IF EXISTS insert_page_members ON public.page_members; +CREATE POLICY insert_page_members ON public.page_members +FOR INSERT TO authenticated WITH CHECK ( + EXISTS (SELECT 1 FROM public.pages p WHERE p.id = page_id AND p.owner_id = auth.uid()) + OR ( + user_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM public.page_invitations pi + WHERE pi.page_id = public.page_members.page_id + AND pi.invitee_email = (SELECT email FROM public.profiles WHERE id = auth.uid()) + AND pi.status = 'pending' + AND pi.role = public.page_members.role + ) + ) +); diff --git a/supabase/migrations/20260816000000_fix_pages_select_returning.sql b/supabase/migrations/20260816000000_fix_pages_select_returning.sql new file mode 100644 index 0000000..023b649 --- /dev/null +++ b/supabase/migrations/20260816000000_fix_pages_select_returning.sql @@ -0,0 +1,25 @@ +-- P2 bugfix: INSERT ... RETURNING on pages fails RLS (42501) even for the +-- page owner. createPage (services/graph.ts) and PostgREST return=representation +-- both generate INSERT ... RETURNING *, and the only authenticated SELECT +-- policy (select_pages = can_access_page(id)) subqueries the pages table +-- itself. At RETURNING check time the new row is not yet visible to that +-- self-subquery (statement snapshot), so the policy evaluates false and the +-- insert is rejected. Plain INSERT (no RETURNING) was unaffected, which is +-- why workspace creation (slot-based select_workspaces) worked. +-- +-- Fix: rewrite select_pages slot-based — owner_id / is_public live on the +-- row being checked; the page_members subquery targets a different table +-- (member rows exist for real pages), so it is unaffected by the new-row +-- visibility gap. Semantics are identical to can_access_page(id) for +-- existing rows (owner OR public OR member). + +DROP POLICY IF EXISTS select_pages ON public.pages; +CREATE POLICY select_pages ON public.pages +FOR SELECT TO authenticated USING ( + owner_id = auth.uid() + OR is_public = true + OR EXISTS ( + SELECT 1 FROM public.page_members m + WHERE m.page_id = pages.id AND m.user_id = auth.uid() + ) +); diff --git a/supabase/migrations/20260816000001_document_versions_page_only.sql b/supabase/migrations/20260816000001_document_versions_page_only.sql new file mode 100644 index 0000000..f2d3875 --- /dev/null +++ b/supabase/migrations/20260816000001_document_versions_page_only.sql @@ -0,0 +1,9 @@ +-- P2 review follow-up: document_versions.document_id was NOT NULL from the +-- legacy schema, but page-backed version records (version-history.tsx +-- checkpoint save, app/api/version/route.ts page branch) insert page-only +-- rows and omit document_id — every such insert fails with a NOT NULL +-- violation. Allow page-only records; legacy rows written before the +-- cutover keep their document_id. + +ALTER TABLE public.document_versions + ALTER COLUMN document_id DROP NOT NULL; \ No newline at end of file diff --git a/supabase/migrations/20260816000002_invitation_update_immutability.sql b/supabase/migrations/20260816000002_invitation_update_immutability.sql new file mode 100644 index 0000000..8d6d0a1 --- /dev/null +++ b/supabase/migrations/20260816000002_invitation_update_immutability.sql @@ -0,0 +1,42 @@ +-- P2 review follow-up: update_page_invitations_invitee validates only status +-- in its WITH CHECK (status IN ('accepted','declined')), so an invitee can +-- pivot page_id, role, invitee_email, inviter_id, or token in the same UPDATE +-- that resolves the invite. Policies cannot compare against the old row, so +-- enforce column immutability with a BEFORE UPDATE trigger: only status may +-- change, only while the invite is pending, and only to accepted/declined. +-- The app flow (insert member while pending, then flip status) is unaffected; +-- the invitee WITH CHECK policy remains as defense in depth. + +CREATE OR REPLACE FUNCTION public.restrict_invitation_update() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF ( + OLD.page_id IS DISTINCT FROM NEW.page_id + OR OLD.inviter_id IS DISTINCT FROM NEW.inviter_id + OR OLD.invitee_email IS DISTINCT FROM NEW.invitee_email + OR OLD.role IS DISTINCT FROM NEW.role + OR OLD.token IS DISTINCT FROM NEW.token + ) THEN + RAISE EXCEPTION 'invitation immutable fields cannot be changed'; + END IF; + + IF OLD.status <> 'pending' THEN + RAISE EXCEPTION 'invitation has already been resolved'; + END IF; + + IF NEW.status NOT IN ('accepted', 'declined') THEN + RAISE EXCEPTION 'invitation status may only be set to accepted or declined'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS restrict_invitation_update ON public.page_invitations; +CREATE TRIGGER restrict_invitation_update +BEFORE UPDATE ON public.page_invitations +FOR EACH ROW +WHEN (OLD.status IS DISTINCT FROM NEW.status) +EXECUTE FUNCTION public.restrict_invitation_update(); \ No newline at end of file diff --git a/supabase/migrations/20260816000003_pin_invitation_trigger_search_path.sql b/supabase/migrations/20260816000003_pin_invitation_trigger_search_path.sql new file mode 100644 index 0000000..46cc6f5 --- /dev/null +++ b/supabase/migrations/20260816000003_pin_invitation_trigger_search_path.sql @@ -0,0 +1,31 @@ +-- Advisor follow-up: pin the trigger function's search_path (it only touches +-- OLD/NEW records, no table access, but the database linter flags the mutable +-- default). + +CREATE OR REPLACE FUNCTION public.restrict_invitation_update() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF ( + OLD.page_id IS DISTINCT FROM NEW.page_id + OR OLD.inviter_id IS DISTINCT FROM NEW.inviter_id + OR OLD.invitee_email IS DISTINCT FROM NEW.invitee_email + OR OLD.role IS DISTINCT FROM NEW.role + OR OLD.token IS DISTINCT FROM NEW.token + ) THEN + RAISE EXCEPTION 'invitation immutable fields cannot be changed'; + END IF; + + IF OLD.status <> 'pending' THEN + RAISE EXCEPTION 'invitation has already been resolved'; + END IF; + + IF NEW.status NOT IN ('accepted', 'declined') THEN + RAISE EXCEPTION 'invitation status may only be set to accepted or declined'; + END IF; + + RETURN NEW; +END; +$$; \ No newline at end of file diff --git a/supabase/migrations/20260816000004_document_versions_single_owner.sql b/supabase/migrations/20260816000004_document_versions_single_owner.sql new file mode 100644 index 0000000..cce0de2 --- /dev/null +++ b/supabase/migrations/20260816000004_document_versions_single_owner.sql @@ -0,0 +1,8 @@ +-- Review follow-up: a version record must belong to exactly one entity — +-- either a legacy document or a page. All writers already produce exactly one +-- (version-history.tsx and /api/version page branch write page_id; the legacy +-- paths write document_id), so this pins the invariant. + +ALTER TABLE public.document_versions + ADD CONSTRAINT document_versions_single_owner_check + CHECK (num_nonnulls(document_id, page_id) = 1); \ No newline at end of file diff --git a/supabase/migrations/20260816000005_invitation_trigger_always_fires.sql b/supabase/migrations/20260816000005_invitation_trigger_always_fires.sql new file mode 100644 index 0000000..45bfb57 --- /dev/null +++ b/supabase/migrations/20260816000005_invitation_trigger_always_fires.sql @@ -0,0 +1,45 @@ +-- Review follow-up: the BEFORE UPDATE trigger fired only when status changed +-- (WHEN OLD.status IS DISTINCT FROM NEW.status), leaving an owner-side gap — +-- an owner can edit non-status columns while leaving status unchanged (the +-- invitee side was covered by the WITH CHECK policy; the managed/owner policy +-- has none). Fire on every row update. True no-op updates (nothing changed) +-- are still allowed so idempotent retries keep working. + +CREATE OR REPLACE FUNCTION public.restrict_invitation_update() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF ( + OLD.page_id IS DISTINCT FROM NEW.page_id + OR OLD.inviter_id IS DISTINCT FROM NEW.inviter_id + OR OLD.invitee_email IS DISTINCT FROM NEW.invitee_email + OR OLD.role IS DISTINCT FROM NEW.role + OR OLD.token IS DISTINCT FROM NEW.token + ) THEN + RAISE EXCEPTION 'invitation immutable fields cannot be changed'; + END IF; + + -- No-op update (only status field examined below; no other column differs). + IF OLD.status IS NOT DISTINCT FROM NEW.status THEN + RETURN NEW; + END IF; + + IF OLD.status <> 'pending' THEN + RAISE EXCEPTION 'invitation has already been resolved'; + END IF; + + IF NEW.status NOT IN ('accepted', 'declined') THEN + RAISE EXCEPTION 'invitation status may only be set to accepted or declined'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS restrict_invitation_update ON public.page_invitations; +CREATE TRIGGER restrict_invitation_update +BEFORE UPDATE ON public.page_invitations +FOR EACH ROW +EXECUTE FUNCTION public.restrict_invitation_update(); \ No newline at end of file diff --git a/tests/unit/dashboard-refetch-on-auth.test.tsx b/tests/unit/dashboard-refetch-on-auth.test.tsx index 130a558..81f55a6 100644 --- a/tests/unit/dashboard-refetch-on-auth.test.tsx +++ b/tests/unit/dashboard-refetch-on-auth.test.tsx @@ -7,11 +7,6 @@ import { GlobalHeaderProvider } from '@/components/layout/global-header-context' let authCallback: ((event: string, session: any) => void) | null = null -const fetchOwned = vi.fn() -const fetchShared = vi.fn() -const fetchInvites = vi.fn() -const createDocument = vi.fn() - vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), })) @@ -27,13 +22,20 @@ vi.mock('@/lib/supabase', () => ({ }, })) -vi.mock('@/services/db', () => ({ - fetchOwnedDocuments: (...args: any[]) => fetchOwned(...args), - fetchSharedDocuments: (...args: any[]) => fetchShared(...args), - fetchPendingInvitations: (...args: any[]) => fetchInvites(...args), - createDocument: (...args: any[]) => createDocument(...args), - deleteDocument: vi.fn(), - updateDocumentTitle: vi.fn(), +const ensureWorkspace = vi.fn() +const fetchWorkspacePages = vi.fn() +const fetchSharedPages = vi.fn() +const fetchPageInvites = vi.fn() +const createPage = vi.fn() + +vi.mock('@/services/graph', () => ({ + ensureWorkspace: (...args: any[]) => ensureWorkspace(...args), + fetchWorkspacePages: (...args: any[]) => fetchWorkspacePages(...args), + fetchSharedPages: (...args: any[]) => fetchSharedPages(...args), + fetchPendingPageInvitations: (...args: any[]) => fetchPageInvites(...args), + createPage: (...args: any[]) => createPage(...args), + deletePage: vi.fn(), + updatePageTitle: vi.fn(), })) vi.mock('@/components/invitations', () => ({ default: () =>
    })) @@ -63,77 +65,79 @@ function renderDashboard() { describe('Dashboard refetch on auth', () => { beforeEach(() => { authCallback = null - fetchOwned.mockReset() - fetchShared.mockReset() - fetchInvites.mockReset() - fetchOwned.mockResolvedValue([]) - fetchShared.mockResolvedValue([]) - fetchInvites.mockResolvedValue([]) + ensureWorkspace.mockReset() + fetchWorkspacePages.mockReset() + fetchSharedPages.mockReset() + fetchPageInvites.mockReset() + ensureWorkspace.mockResolvedValue({ id: 'ws-1', owner_id: 'user-1' }) + fetchWorkspacePages.mockResolvedValue([]) + fetchSharedPages.mockResolvedValue([]) + fetchPageInvites.mockResolvedValue([]) }) it('refetches documents when a fresh session is established (SIGNED_IN)', async () => { renderDashboard() - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(1)) act(() => { authCallback?.('SIGNED_IN', { user: { id: 'user-1', email: 'author@example.com' } }) }) - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(2)) }) it('does not refetch on unrelated auth events', async () => { renderDashboard() - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(1)) act(() => { authCallback?.('TOKEN_REFRESHED', { user: { id: 'user-1', email: 'author@example.com' } }) authCallback?.('USER_UPDATED', { user: { id: 'user-1', email: 'author@example.com' } }) }) - expect(fetchOwned).toHaveBeenCalledTimes(1) + expect(fetchWorkspacePages).toHaveBeenCalledTimes(1) }) it('shows a retryable error state instead of the empty state when fetching fails', async () => { - fetchOwned.mockRejectedValueOnce(new Error('PGRST303')) + fetchWorkspacePages.mockRejectedValueOnce(new Error('PGRST303')) renderDashboard() - expect(await screen.findByText(/couldn't load your documents/i)).toBeInTheDocument() + expect(await screen.findByText(/couldn't load your pages/i)).toBeInTheDocument() expect(screen.queryByText(/welcome to lekhan/i)).not.toBeInTheDocument() - fetchOwned.mockResolvedValueOnce([{ id: 'doc-1', title: 'My Doc', owner_id: 'user-1', updated_at: new Date().toISOString() }]) + fetchWorkspacePages.mockResolvedValueOnce([{ id: 'doc-1', title: 'My Doc', owner_id: 'user-1', updated_at: new Date().toISOString() }]) act(() => { screen.getByRole('button', { name: /try again/i }).click() }) - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(2)) - expect(screen.queryByText(/couldn't load your documents/i)).not.toBeInTheDocument() + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(2)) + expect(screen.queryByText(/couldn't load your pages/i)).not.toBeInTheDocument() }) it('replaces the error view with a loading state during a retry', async () => { - fetchOwned.mockRejectedValueOnce(new Error('PGRST303')) + fetchWorkspacePages.mockRejectedValueOnce(new Error('PGRST303')) renderDashboard() - expect(await screen.findByText(/couldn't load your documents/i)).toBeInTheDocument() + expect(await screen.findByText(/couldn't load your pages/i)).toBeInTheDocument() const ownedDeferred = deferred() const sharedDeferred = deferred() const invitesDeferred = deferred() - fetchOwned.mockImplementationOnce(() => ownedDeferred.promise) - fetchShared.mockImplementationOnce(() => sharedDeferred.promise) - fetchInvites.mockImplementationOnce(() => invitesDeferred.promise) + fetchWorkspacePages.mockImplementationOnce(() => ownedDeferred.promise) + fetchSharedPages.mockImplementationOnce(() => sharedDeferred.promise) + fetchPageInvites.mockImplementationOnce(() => invitesDeferred.promise) act(() => { screen.getByRole('button', { name: /try again/i }).click() }) - expect(screen.queryByText(/couldn't load your documents/i)).not.toBeInTheDocument() + expect(screen.queryByText(/couldn't load your pages/i)).not.toBeInTheDocument() expect(screen.getByText('Loading dashboard...')).toBeInTheDocument() await act(async () => { @@ -143,7 +147,7 @@ describe('Dashboard refetch on auth', () => { }) expect(screen.queryByText('Loading dashboard...')).not.toBeInTheDocument() - expect(screen.queryByText(/couldn't load your documents/i)).not.toBeInTheDocument() + expect(screen.queryByText(/couldn't load your pages/i)).not.toBeInTheDocument() }) it('keeps the newer result when a refreshed request resolves before the original', async () => { @@ -154,23 +158,23 @@ describe('Dashboard refetch on auth', () => { const secondShared = deferred() const secondInvites = deferred() - fetchOwned.mockImplementationOnce(() => firstOwned.promise) - fetchShared.mockImplementationOnce(() => firstShared.promise) - fetchInvites.mockImplementationOnce(() => firstInvites.promise) + fetchWorkspacePages.mockImplementationOnce(() => firstOwned.promise) + fetchSharedPages.mockImplementationOnce(() => firstShared.promise) + fetchPageInvites.mockImplementationOnce(() => firstInvites.promise) renderDashboard() - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(1)) - fetchOwned.mockImplementationOnce(() => secondOwned.promise) - fetchShared.mockImplementationOnce(() => secondShared.promise) - fetchInvites.mockImplementationOnce(() => secondInvites.promise) + fetchWorkspacePages.mockImplementationOnce(() => secondOwned.promise) + fetchSharedPages.mockImplementationOnce(() => secondShared.promise) + fetchPageInvites.mockImplementationOnce(() => secondInvites.promise) act(() => { authCallback?.('SIGNED_IN', { user: { id: 'user-1', email: 'author@example.com' } }) }) - await waitFor(() => expect(fetchOwned).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(fetchWorkspacePages).toHaveBeenCalledTimes(2)) // Resolve the newer (refreshed) request first with two docs. await act(async () => { diff --git a/tests/unit/db-graph.test.ts b/tests/unit/db-graph.test.ts index 62b649e..535d323 100644 --- a/tests/unit/db-graph.test.ts +++ b/tests/unit/db-graph.test.ts @@ -1,5 +1,6 @@ import { vi, describe, it, expect, beforeEach } from 'vitest' import { supabase } from '@/lib/supabase' +import { PageInvitation } from '@/types' import { fetchWorkspaces, fetchWorkspacePages, @@ -11,6 +12,20 @@ import { fetchPageBacklinks, fetchPageTags, fetchWorkspaceGraph, + ensureWorkspace, + fetchSharedPages, + fetchPageMemberRole, + fetchPageMembers, + removePageMember, + updatePageMemberRole, + createPageInvitation, + fetchPendingPageInvitations, + acceptPageInvitation, + declinePageInvitation, + fetchPageInvitationDetails, + fetchMentionablePageCollaborators, + fetchOwnedPagesWithMembers, + fetchVersionsForEntity, } from '@/services/graph' vi.mock('@/lib/supabase', () => { @@ -20,8 +35,12 @@ vi.mock('@/lib/supabase', () => { update: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + neq: vi.fn().mockReturnThis(), order: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), single: vi.fn().mockResolvedValue({ data: null, error: null }), + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), then: vi.fn((cb) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)), } return { @@ -31,9 +50,9 @@ vi.mock('@/lib/supabase', () => { } }) -describe('Graph Service', () => { - const mockBuilder = (supabase.from as any)() +const mockBuilder = (supabase.from as any)() +describe('Graph Service', () => { beforeEach(() => { vi.clearAllMocks() mockBuilder.select.mockReturnThis() @@ -41,8 +60,11 @@ describe('Graph Service', () => { mockBuilder.update.mockReturnThis() mockBuilder.delete.mockReturnThis() mockBuilder.eq.mockReturnThis() + mockBuilder.in.mockReturnThis() mockBuilder.order.mockReturnThis() + mockBuilder.or.mockReturnThis() mockBuilder.single.mockResolvedValue({ data: null, error: null }) + mockBuilder.maybeSingle.mockResolvedValue({ data: null, error: null }) mockBuilder.then.mockImplementation((cb: any) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)) }) @@ -161,4 +183,244 @@ describe('Graph Service', () => { expect(supabase.from).toHaveBeenCalledWith('pages') expect(supabase.from).toHaveBeenCalledWith('page_links') }) +}) + +describe('Graph Service P2 additions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('ensureWorkspace returns an existing workspace', async () => { + const ws = { id: 'ws-1', name: 'My Workspace', owner_id: 'user-123', is_team: false, created_at: '', updated_at: '' } + mockBuilder.maybeSingle.mockResolvedValue({ data: ws, error: null }) + const result = await ensureWorkspace('user-123') + expect(supabase.from).toHaveBeenCalledWith('workspaces') + expect(mockBuilder.eq).toHaveBeenCalledWith('owner_id', 'user-123') + expect(result).toEqual(ws) + }) + + it('ensureWorkspace inserts a workspace when none exists', async () => { + mockBuilder.maybeSingle.mockResolvedValue({ data: null, error: null }) + mockBuilder.single.mockResolvedValue({ data: { id: 'ws-2', owner_id: 'user-123' }, error: null }) + const result = await ensureWorkspace('user-123') + expect(mockBuilder.insert).toHaveBeenCalledWith({ owner_id: 'user-123' }) + expect(result).toEqual({ id: 'ws-2', owner_id: 'user-123' }) + }) + + it('ensureWorkspace refetches when a concurrent insert hits a unique violation', async () => { + mockBuilder.maybeSingle.mockResolvedValue({ data: null, error: null }) + mockBuilder.single + .mockResolvedValueOnce({ data: null, error: { code: '23505', message: 'duplicate key value violates unique constraint' } }) + .mockResolvedValueOnce({ data: { id: 'ws-3', owner_id: 'user-123' }, error: null }) + const result = await ensureWorkspace('user-123') + expect(result).toEqual({ id: 'ws-3', owner_id: 'user-123' }) + }) + + it('fetchSharedPages queries page_members with page embed', async () => { + const shared = [{ role: 'editor', pages: { id: 'p-1', title: 'A' } }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: shared, error: null }).then(onfulfilled) + ) + const result = await fetchSharedPages('user-123') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'user-123') + expect(result).toEqual(shared) + }) + + it('fetchPageMemberRole returns the role for a member', async () => { + mockBuilder.single.mockResolvedValue({ data: { role: 'viewer' }, error: null }) + const role = await fetchPageMemberRole('p-1', 'user-123') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(role).toBe('viewer') + }) + + it('fetchPageMemberRole returns null on error', async () => { + mockBuilder.single.mockResolvedValue({ data: null, error: { message: 'no rows' } }) + const role = await fetchPageMemberRole('p-1', 'user-123') + expect(role).toBeNull() + }) + + it('fetchPageMembers returns members with profile embed', async () => { + const members = [{ id: 'm-1', page_id: 'p-1', user_id: 'u-1', role: 'editor' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: members, error: null }).then(onfulfilled) + ) + const result = await fetchPageMembers('p-1') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(result).toEqual(members) + }) + + it('removePageMember deletes the membership', async () => { + await removePageMember('p-1', 'u-9') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(mockBuilder.delete).toHaveBeenCalled() + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'u-9') + }) + + it('updatePageMemberRole updates the role', async () => { + await updatePageMemberRole('p-1', 'u-9', 'viewer') + expect(mockBuilder.update).toHaveBeenCalledWith({ role: 'viewer' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('page_id', 'p-1') + expect(mockBuilder.eq).toHaveBeenCalledWith('user_id', 'u-9') + }) + + it('createPageInvitation counts members and pending invites against the plan limit', async () => { + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + let counts = 0 + mockBuilder.then.mockImplementation((onfulfilled: any) => + Promise.resolve({ data: [], count: counts++, error: null }).then(onfulfilled) + ) + await createPageInvitation('p-1', 'owner-1', 'x@test.com', 'viewer', 'tok-1') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.insert).toHaveBeenCalledWith({ + page_id: 'p-1', + inviter_id: 'owner-1', + invitee_email: 'x@test.com', + role: 'viewer', + token: 'tok-1', + status: 'pending', + }) + }) + + it('createPageInvitation rejects at the collaborator limit without inserting', async () => { + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + mockBuilder.then + .mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: [], count: 2, error: null }).then(onfulfilled) + ) + .mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: [], count: 0, error: null }).then(onfulfilled) + ) + await expect(createPageInvitation('p-1', 'owner-1', 'x@test.com', 'viewer', 'tok-1')).rejects.toThrow('Collaborator limit reached') + expect(mockBuilder.insert).not.toHaveBeenCalled() + }) + + it('createPageInvitation propagates precheck failures without inserting', async () => { + mockBuilder.single.mockResolvedValue({ data: null, error: { message: 'page fetch failed' } }) + await expect(createPageInvitation('p-1', 'owner-1', 'x@test.com', 'viewer', 'tok-1')).rejects.toThrow('page fetch failed') + expect(mockBuilder.insert).not.toHaveBeenCalled() + }) + + it('fetchPendingPageInvitations filters by email and pending status', async () => { + const invites = [{ id: 'i-1', page_id: 'p-1', role: 'editor' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: invites, error: null }).then(onfulfilled) + ) + const result = await fetchPendingPageInvitations('x@test.com') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.eq).toHaveBeenCalledWith('invitee_email', 'x@test.com') + expect(mockBuilder.eq).toHaveBeenCalledWith('status', 'pending') + expect(result).toEqual(invites) + }) + + it('acceptPageInvitation inserts a member then marks the invite accepted', async () => { + const invite = { id: 'i-1', page_id: 'p-1', role: 'editor', status: 'pending' } as PageInvitation + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + mockBuilder.then.mockImplementation((onfulfilled: any) => + Promise.resolve({ data: [], count: 0, error: null }).then(onfulfilled) + ) + await acceptPageInvitation(invite, 'user-123') + expect(mockBuilder.insert).toHaveBeenCalledWith({ page_id: 'p-1', user_id: 'user-123', role: 'editor' }) + expect(mockBuilder.update).toHaveBeenCalledWith({ status: 'accepted' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('id', 'i-1') + }) + + it('acceptPageInvitation rejects a non-pending invite without inserting', async () => { + const invite = { id: 'i-1', page_id: 'p-1', role: 'editor', status: 'declined' } as PageInvitation + await expect(acceptPageInvitation(invite, 'user-123')).rejects.toThrow('This invitation is no longer available') + expect(mockBuilder.insert).not.toHaveBeenCalled() + expect(mockBuilder.update).not.toHaveBeenCalled() + }) + + it('acceptPageInvitation rejects at the collaborator limit without inserting', async () => { + const invite = { id: 'i-1', page_id: 'p-1', role: 'editor', status: 'pending' } as PageInvitation + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + mockBuilder.then + .mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: [], count: 2, error: null }).then(onfulfilled) + ) + .mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: [], count: 0, error: null }).then(onfulfilled) + ) + await expect(acceptPageInvitation(invite, 'user-123')).rejects.toThrow('Collaborator limit reached') + expect(mockBuilder.insert).not.toHaveBeenCalled() + expect(mockBuilder.update).not.toHaveBeenCalled() + }) + + it('acceptPageInvitation propagates count-query failures without inserting', async () => { + const invite = { id: 'i-1', page_id: 'p-1', role: 'editor', status: 'pending' } as PageInvitation + mockBuilder.single.mockResolvedValue({ data: { id: 'p-1', owner_id: 'owner-1' }, error: null }) + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: [], count: null, error: { message: 'count failed' } }).then(onfulfilled) + ) + await expect(acceptPageInvitation(invite, 'user-123')).rejects.toThrow('count failed') + expect(mockBuilder.insert).not.toHaveBeenCalled() + expect(mockBuilder.update).not.toHaveBeenCalled() + }) + + it('declinePageInvitation marks the invite declined', async () => { + await declinePageInvitation('i-1') + expect(mockBuilder.update).toHaveBeenCalledWith({ status: 'declined' }) + expect(mockBuilder.eq).toHaveBeenCalledWith('id', 'i-1') + }) + + it('fetchPageInvitationDetails fetches by token', async () => { + const invite = { id: 'i-1', page_id: 'p-1', token: 'tok-1' } + mockBuilder.single.mockResolvedValue({ data: invite, error: null }) + const result = await fetchPageInvitationDetails('tok-1') + expect(supabase.from).toHaveBeenCalledWith('page_invitations') + expect(mockBuilder.eq).toHaveBeenCalledWith('token', 'tok-1') + expect(result).toEqual(invite) + }) + + it('fetchMentionablePageCollaborators returns owner and editor members', async () => { + mockBuilder.single.mockResolvedValue({ + data: { owner_id: 'owner-1', profiles: { id: 'owner-1', email: 'o@test.com', full_name: 'Owner' } }, + error: null, + }) + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ + data: [{ role: 'editor', profiles: { id: 'ed-1', email: 'e@test.com', full_name: 'Editor' } }], + error: null, + }).then(onfulfilled) + ) + const result = await fetchMentionablePageCollaborators('p-1') + expect(supabase.from).toHaveBeenCalledWith('pages') + expect(supabase.from).toHaveBeenCalledWith('page_members') + expect(result).toEqual([ + { id: 'owner-1', email: 'o@test.com', full_name: 'Owner' }, + { id: 'ed-1', email: 'e@test.com', full_name: 'Editor' }, + ]) + }) + + it('fetchOwnedPagesWithMembers embeds page members', async () => { + const pages = [{ id: 'p-1', page_members: [{ id: 'm-1', user_id: 'u-1', role: 'editor' }] }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: pages, error: null }).then(onfulfilled) + ) + const result = await fetchOwnedPagesWithMembers('user-123') + expect(supabase.from).toHaveBeenCalledWith('pages') + expect(mockBuilder.eq).toHaveBeenCalledWith('owner_id', 'user-123') + expect(result).toEqual(pages) + }) + + it('fetchVersionsForEntity queries page_id OR document_id', async () => { + const entityId = '11111111-1111-4111-8111-111111111111' + const versions = [{ id: 'v-1', version_name: 'Draft' }] + mockBuilder.then.mockImplementationOnce((onfulfilled: any) => + Promise.resolve({ data: versions, error: null }).then(onfulfilled) + ) + const result = await fetchVersionsForEntity(entityId) + expect(supabase.from).toHaveBeenCalledWith('document_versions') + expect(mockBuilder.or).toHaveBeenCalledWith(`page_id.eq.${entityId},document_id.eq.${entityId}`) + expect(result).toEqual(versions) + }) + + it('fetchVersionsForEntity rejects a non-UUID id without querying', async () => { + const result = await fetchVersionsForEntity('p-1') + expect(result).toEqual([]) + expect(supabase.from).not.toHaveBeenCalledWith('document_versions') + }) }) \ No newline at end of file diff --git a/tests/unit/editor-formatting.test.tsx b/tests/unit/editor-formatting.test.tsx index 05c4dc4..39e8cdc 100644 --- a/tests/unit/editor-formatting.test.tsx +++ b/tests/unit/editor-formatting.test.tsx @@ -30,12 +30,14 @@ vi.mock('@/lib/supabase', () => ({ } })) -// Mock fetch Document calls +vi.mock('@/services/graph', () => ({ + fetchPageDetails: vi.fn().mockResolvedValue({ owner_id: 'test-user', is_public: false }), + fetchPageMemberRole: vi.fn().mockResolvedValue('owner'), + updatePageTitle: vi.fn().mockResolvedValue(true), + fetchMentionablePageCollaborators: vi.fn().mockResolvedValue([]), +})) + vi.mock('@/services/db', () => ({ - fetchDocumentDetails: vi.fn().mockResolvedValue({ owner_id: 'test-user', is_public: false }), - fetchMemberRole: vi.fn().mockResolvedValue('owner'), - updateDocumentTitle: vi.fn().mockResolvedValue(true), - fetchMentionableCollaborators: vi.fn().mockResolvedValue([]), getUserAICredits: vi.fn().mockResolvedValue({ plan: 'free', totalAllocated: 50, usedCredits: 0, remainingCredits: 50 }), })) @@ -52,8 +54,8 @@ describe('EditorWorkspace Formatting', () => { render( diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index 8603123..e6aef30 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -213,15 +213,7 @@ describe('Server Pages Cutover & Graph Index Integration', () => { expect(role).toBe('editor') }) - it('verifyUserRole honors live document_members grants on mapped pages after migration', async () => { - let pagesLookupCalls = 0 - const pagesMaybeSingle = vi.fn(async () => { - pagesLookupCalls += 1 - if (pagesLookupCalls === 1) { - return { data: { type: 'page', owner_id: 'user-123', is_public: false }, error: null } - } - return { data: { source_document_id: 'doc-legacy' }, error: null } - }) + it('verifyUserRole ignores document_members grants on mapped pages (page-only authority)', async () => { const mockSupabase = { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-456' } }, error: null }), @@ -230,7 +222,12 @@ describe('Server Pages Cutover & Graph Index Integration', () => { if (table === 'pages') { return { select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle: pagesMaybeSingle }), + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { type: 'page', owner_id: 'user-123', is_public: false, source_document_id: 'doc-legacy' }, + error: null, + }), + }), }), } } @@ -261,42 +258,28 @@ describe('Server Pages Cutover & Graph Index Integration', () => { } as any const role = await auth.verifyUserRole(mockSupabase, 'page-1', 'token-1') - expect(role).toBe('editor') + expect(role).toBeNull() }) - it('verifyUserRole denies a mapped page after the document_members grant is revoked', async () => { - let pagesLookupCalls = 0 - const pagesMaybeSingle = vi.fn(async () => { - pagesLookupCalls += 1 - if (pagesLookupCalls === 1) { - return { data: { type: 'page', owner_id: 'user-123', is_public: false }, error: null } - } - return { data: { source_document_id: 'doc-legacy' }, error: null } - }) + it('verifyUserRole grants "viewer" to an authenticated non-member on a public page', async () => { const mockSupabase = { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-456' } }, error: null }), }, from: vi.fn().mockImplementation((table: string) => { if (table === 'pages') { - return { - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ maybeSingle: pagesMaybeSingle }), - }), - } - } - if (table === 'page_members') { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ data: null, error: null }), + maybeSingle: vi.fn().mockResolvedValue({ + data: { type: 'page', owner_id: 'user-123', is_public: true }, + error: null, }), }), }), } } - if (table === 'document_members') { + if (table === 'page_members') { return { select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ @@ -312,7 +295,7 @@ describe('Server Pages Cutover & Graph Index Integration', () => { } as any const role = await auth.verifyUserRole(mockSupabase, 'page-1', 'token-1') - expect(role).toBeNull() + expect(role).toBe('viewer') }) it('getDocumentOwnerPlanLimit reads the owner plan via pages', async () => { diff --git a/tests/unit/settings-tabs.test.tsx b/tests/unit/settings-tabs.test.tsx index b0628fd..ed640b8 100644 --- a/tests/unit/settings-tabs.test.tsx +++ b/tests/unit/settings-tabs.test.tsx @@ -28,7 +28,7 @@ describe('SettingsClient Tabbed Navigation', () => { it('renders tab buttons and switches between tabs', () => { render( - + ) expect(screen.getByRole('button', { name: /Profile & Security/i })).toBeInTheDocument() diff --git a/tests/unit/version-history-reassurance.test.tsx b/tests/unit/version-history-reassurance.test.tsx index 2b6a9d6..ff34689 100644 --- a/tests/unit/version-history-reassurance.test.tsx +++ b/tests/unit/version-history-reassurance.test.tsx @@ -9,7 +9,9 @@ vi.mock('@/lib/supabase', () => ({ from: vi.fn(() => ({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), - order: vi.fn().mockResolvedValue({ data: [], error: null }), + order: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), + then: vi.fn((cb: any) => Promise.resolve({ data: [], count: 0, error: null }).then(cb)), })), }, })) diff --git a/types/index.ts b/types/index.ts index 2924ae5..8b16a4e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -41,7 +41,8 @@ export interface DocumentInvitation { export interface DocumentVersion { id: string - document_id: string + document_id: string | null + page_id?: string | null version_name: string created_at: string created_by: string @@ -80,6 +81,7 @@ export interface Page { export interface PageLink { id: string + workspace_id: string from_page_id: string to_page_id: string | null to_title: string @@ -98,3 +100,40 @@ export interface Backlink { from_page_id: string from_title: string } + +export type MemberRole = 'owner' | 'editor' | 'viewer' + +export interface PageMember { + id: string + page_id: string + user_id: string + role: MemberRole + created_at: string + profiles?: { id: string; email: string; full_name: string | null; avatar_url?: string | null } +} + +export interface PageInvitation { + id: string + page_id: string + inviter_id: string + invitee_email: string + role: 'editor' | 'viewer' + token: string + status: 'pending' | 'accepted' | 'declined' + created_at: string + pages?: { title: string } + profiles?: { email: string; full_name: string | null } +} + +// Projection returned by invitation queries: every PageInvitation field is +// selected, and the embedded relations come back in array form per the +// PostgREST client's generic inference (normalized to objects at runtime). +export interface PageInvitationProjection extends Omit { + pages?: { title: string }[] + profiles?: { email: string; full_name: string | null }[] +} + +export interface MemberPageItem { + role: MemberRole + pages: Page +}