diff --git a/.env.local.example b/.env.local.example index acd54827..485fd803 100644 --- a/.env.local.example +++ b/.env.local.example @@ -6,3 +6,9 @@ SMITHERY_API_KEY="your_smithery_api_key_here" # NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN is already used by mapbox-map.tsx # Ensure it's also in your .env.local file if you haven't set it up yet. # NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN="your_mapbox_public_token_here" + +# Supabase Credentials +NEXT_PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL_HERE" +NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY_HERE" +SUPABASE_SERVICE_ROLE_KEY="YOUR_SUPABASE_SERVICE_ROLE_KEY_HERE" +DATABASE_URL="postgresql://postgres:[YOUR-POSTGRES-PASSWORD]@[YOUR-SUPABASE-DB-HOST]:[PORT]/postgres" diff --git a/app/actions.tsx b/app/actions.tsx index 30622b24..eeb10cfb 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -273,6 +273,8 @@ export const AI = createAI({ onGetUIState: async () => { 'use server'; + // TODO: This needs to be adapted to use server-side auth if needed for initial UI state based on user. + // For now, it only uses getAIState(). const aiState = getAIState() as AIState; if (aiState) { const uiState = getUIStateFromAIState(aiState); @@ -290,13 +292,13 @@ export const AI = createAI({ const { chatId, messages } = state; const createdAt = new Date(); - const userId = 'anonymous'; + // const userId = 'anonymous'; // Replaced with actual user ID const path = `/search/${chatId}`; const title = messages.length > 0 ? JSON.parse(messages[0].content)?.input?.substring(0, 100) || - 'Untitled' - : 'Untitled'; + 'Untitled Chat' // Default title consistency + : 'Untitled Chat'; // Add an 'end' message at the end to determine if the history needs to be reloaded const updatedMessages: AIMessage[] = [ ...messages, @@ -308,15 +310,28 @@ export const AI = createAI({ }, ]; - const chat: Chat = { + + // Get the actual user ID using server-side auth + const { getCurrentUserIdOnServer } = await import('@/lib/auth/get-current-user'); + const actualUserId = await getCurrentUserIdOnServer(); + + if (!actualUserId) { + console.error("onSetAIState: User not authenticated. Chat not saved."); + // Optionally, clear the AI state or handle appropriately + // For now, we just won't save if there's no user. + // Or, if chats for anonymous users are allowed with a guest ID, that logic would go here. + return; + } + + const chat: Chat = { // Chat is OldChatType from @/lib/types id: chatId, createdAt, - userId, + userId: actualUserId, // Use the authenticated user's ID path, title, messages: updatedMessages, }; - await saveChat(chat); + await saveChat(chat, actualUserId); // Pass actualUserId to saveChat }, }); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..a8e592ee --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,68 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { saveChat, createMessage, NewChat, NewMessage } from '@/lib/actions/chat-db'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +// import { generateUUID } from '@/lib/utils'; // Assuming generateUUID is in lib/utils as per PR context - not needed for PKs + +// This is a simplified POST handler. PR #533's version might be more complex, +// potentially handling streaming AI responses and then saving. +// For now, this focuses on the database interaction part. +export async function POST(request: NextRequest) { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + + // Example: Distinguish between creating a new chat vs. adding a message to existing chat + // The actual structure of `body` would depend on client-side implementation. + // Let's assume a simple case: creating a new chat with an initial message. + const { title, initialMessageContent, role = 'user' } = body; + + if (!initialMessageContent) { + return NextResponse.json({ error: 'Initial message content is required' }, { status: 400 }); + } + + const newChatData: NewChat = { + // id: generateUUID(), // Drizzle schema now has defaultRandom for UUIDs + userId: userId, + title: title || 'New Chat', // Default title if not provided + // createdAt: new Date(), // Handled by defaultNow() in schema + visibility: 'private', // Default visibility + }; + + // Use a transaction if creating chat and first message together + // For simplicity here, let's assume saveChat handles chat creation and returns ID, then we create a message. + // A more robust `saveChat` might create the chat and first message in one go. + // The `saveChat` in chat-db.ts is designed to handle this. + + const firstMessage: Omit = { + // id: generateUUID(), // Drizzle schema now has defaultRandom for UUIDs + // chatId is omitted as it will be set by saveChat + userId: userId, + role: role as NewMessage['role'], // Ensure role type matches schema expectation + content: initialMessageContent, + // createdAt: new Date(), // Handled by defaultNow() in schema, not strictly needed here + }; + + // The saveChat in chat-db.ts is designed to take initial messages. + const savedChatId = await saveChat(newChatData, [firstMessage]); + + if (!savedChatId) { + return NextResponse.json({ error: 'Failed to save chat' }, { status: 500 }); + } + + // Fetch the newly created chat and message to return (optional, but good for client) + // For now, just return success and the new chat ID. + return NextResponse.json({ message: 'Chat created successfully', chatId: savedChatId }, { status: 201 }); + + } catch (error) { + console.error('Error in POST /api/chat:', error); + let errorMessage = 'Internal Server Error'; + if (error instanceof Error) { + errorMessage = error.message; + } + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/chats/all/route.ts b/app/api/chats/all/route.ts new file mode 100644 index 00000000..d0a3dbb7 --- /dev/null +++ b/app/api/chats/all/route.ts @@ -0,0 +1,37 @@ +// Content for app/api/chats/all/route.ts +import { NextResponse } from 'next/server'; +import { clearHistory as dbClearHistory } from '@/lib/actions/chat-db'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import { revalidatePath } from 'next/cache'; // For revalidating after clearing + +export async function DELETE() { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const success = await dbClearHistory(userId); + if (success) { + revalidatePath('/'); // Revalidate home or relevant pages + revalidatePath('/search'); // Revalidate search path + return NextResponse.json({ message: 'History cleared successfully' }, { status: 200 }); + } else { + // This case might be redundant if dbClearHistory throws an error on failure, + // but kept for explicitness if it returns false for "no error but nothing done". + return NextResponse.json({ error: 'Failed to clear history' }, { status: 500 }); + } + } catch (error) { + console.error('Error clearing history via API:', error); + let errorMessage = 'Internal Server Error clearing history'; + if (error instanceof Error && error.message) { + // Use the error message from dbClearHistory if available (e.g., "User ID is required") + // This depends on dbClearHistory actually throwing or returning specific error messages. + // The current dbClearHistory in chat.ts returns {error: ...} which won't be caught here as an Error instance directly. + // However, the dbClearHistory in chat-db.ts returns boolean. + // Let's assume if dbClearHistory from chat-db.ts (which returns boolean) fails, it's a generic 500. + // If it were to throw, that would be caught. + } + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 00000000..91903e13 --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { getChatsPage } from '@/lib/actions/chat-db'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; + +export async function GET(request: NextRequest) { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + + const DEFAULT_LIMIT = 20; + const MAX_LIMIT = 100; + const DEFAULT_OFFSET = 0; + + let limit = parseInt(searchParams.get('limit') || '', 10); + if (isNaN(limit) || limit < 1 || limit > MAX_LIMIT) { + limit = DEFAULT_LIMIT; + } + + let offset = parseInt(searchParams.get('offset') || '', 10); + if (isNaN(offset) || offset < 0) { + offset = DEFAULT_OFFSET; + } + + const result = await getChatsPage(userId, limit, offset); + return NextResponse.json(result); + } catch (error) { + console.error('Error fetching chats:', error); + return NextResponse.json({ error: 'Internal Server Error fetching chats' }, { status: 500 }); + } +} diff --git a/app/search/[id]/page.tsx b/app/search/[id]/page.tsx index 55224272..8db74186 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -1,41 +1,71 @@ import { notFound, redirect } from 'next/navigation'; import { Chat } from '@/components/chat'; -import { getChat } from '@/lib/actions/chat'; +import { getChat, getChatMessages } from '@/lib/actions/chat'; // Added getChatMessages import { AI } from '@/app/actions'; import { MapDataProvider } from '@/components/map/map-data-context'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; // For server-side auth +import type { AIMessage } from '@/lib/types'; // For AIMessage type +import type { Message as DrizzleMessage } from '@/lib/actions/chat-db'; // For DrizzleMessage type export const maxDuration = 60; export interface SearchPageProps { - params: Promise<{ id: string }>; // Change to Promise + params: Promise<{ id: string }>; // Keep as is for now } export async function generateMetadata({ params }: SearchPageProps) { - const { id } = await params; // Unwrap the Promise - const chat = await getChat(id, 'anonymous'); + const { id } = await params; // Keep as is for now + // TODO: Metadata generation might need authenticated user if chats are private + // For now, assuming getChat can be called or it handles anon access for metadata appropriately + const userId = await getCurrentUserIdOnServer(); // Attempt to get user for metadata + const chat = await getChat(id, userId || 'anonymous'); // Pass userId or 'anonymous' if none return { - title: chat?.title.toString().slice(0, 50) || 'Search', + title: chat?.title?.toString().slice(0, 50) || 'Search', }; } export default async function SearchPage({ params }: SearchPageProps) { - const { id } = await params; // Unwrap the Promise - const userId = 'anonymous'; - const chat = await getChat(id, userId); + const { id } = await params; // Keep as is for now + const userId = await getCurrentUserIdOnServer(); - if (!chat) { + if (!userId) { + // If no user, redirect to login or show appropriate page + // For now, redirecting to home, but a login page would be better. redirect('/'); } - if (chat?.userId !== userId) { + const chat = await getChat(id, userId); + + if (!chat) { + // If chat doesn't exist or user doesn't have access (handled by getChat) notFound(); } + // Fetch messages for the chat + const dbMessages: DrizzleMessage[] = await getChatMessages(chat.id); + + // Transform DrizzleMessages to AIMessages + const initialMessages: AIMessage[] = dbMessages.map((dbMsg): AIMessage => { + return { + id: dbMsg.id, + role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities + content: dbMsg.content, + createdAt: dbMsg.createdAt ? new Date(dbMsg.createdAt) : undefined, + // 'type' and 'name' are not in the basic Drizzle 'messages' schema. + // These would be undefined unless specific logic is added to derive them. + // For instance, if a message with role 'tool' should have a 'name', + // or if some messages have a specific 'type' based on content or other flags. + // This mapping assumes standard user/assistant messages primarily. + }; + }); + return ( diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx deleted file mode 100644 index 1b15890a..00000000 --- a/app/share/[id]/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { notFound } from 'next/navigation'; -import { Chat } from '@/components/chat'; -import { getSharedChat } from '@/lib/actions/chat'; -import { AI } from '@/app/actions'; - -export interface SharePageProps { - params: Promise<{ id: string }>; -} - -export async function generateMetadata({ params }: SharePageProps) { - const { id } = await params; // Unwrap the Promise to get the id - const chat = await getSharedChat(id); - - if (!chat || !chat.sharePath) { - return { - title: 'Not Found', // Fallback title for metadata - }; - } - - return { - title: chat.title.toString().slice(0, 50) || 'Search', - }; -} - -export default async function SharePage({ params }: SharePageProps) { - const { id } = await params; // Unwrap the Promise to get the id - const chat = await getSharedChat(id); - - if (!chat || !chat.sharePath) { - notFound(); - } - - return ( - - - - ); -} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 489d0f7c..ce7bfa3e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/chat-share.tsx b/components/chat-share.tsx index 32d192cb..05b4ccf5 100644 --- a/components/chat-share.tsx +++ b/components/chat-share.tsx @@ -12,7 +12,7 @@ import { DialogDescription, DialogTitle } from './ui/dialog' -import { shareChat } from '@/lib/actions/chat' +// import { shareChat } from '@/lib/actions/chat'; // TODO: Re-evaluate/reimplement sharing with Supabase import { toast } from 'sonner' import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' import { Spinner } from './ui/spinner' @@ -28,38 +28,46 @@ export function ChatShare({ chatId, className }: ChatShareProps) { const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 }) const [shareUrl, setShareUrl] = useState('') - const handleShare = async () => { - startTransition(() => { - setOpen(true) - }) - const result = await shareChat(chatId) - if (!result) { - toast.error('Failed to share chat') - return - } + // const handleShare = async () => { + // startTransition(() => { + // setOpen(true) + // }) + // // TODO: Re-evaluate/reimplement sharing with Supabase + // // const result = await shareChat(chatId) + // // if (!result) { + // // toast.error('Failed to share chat') + // // return + // // } - if (!result.sharePath) { - toast.error('Could not copy link to clipboard') - return - } + // // if (!result.sharePath) { + // // toast.error('Could not copy link to clipboard') + // // return + // // } - const url = new URL(result.sharePath, window.location.href) - setShareUrl(url.toString()) - } + // // const url = new URL(result.sharePath, window.location.href) + // // setShareUrl(url.toString()) + // toast.info("Sharing functionality is currently disabled."); + // setOpen(false); // Close dialog if opened by trigger + // } + + // const handleCopy = () => { + // if (shareUrl) { + // copyToClipboard(shareUrl) + // toast.success('Link copied to clipboard') + // setOpen(false) + // } else { + // toast.error('No link to copy') + // } + // } - const handleCopy = () => { - if (shareUrl) { - copyToClipboard(shareUrl) - toast.success('Link copied to clipboard') - setOpen(false) - } else { - toast.error('No link to copy') - } + // TODO: Re-evaluate/reimplement sharing with Supabase. For now, disable the UI. + if (true) { // Conditionally disable the share button/dialog + return null; // Or return a disabled button: } return (
- setOpen(open)} aria-labelledby="share-dialog-title" @@ -70,7 +78,10 @@ export function ChatShare({ chatId, className }: ChatShareProps) { className="rounded-full" size="icon" variant={'ghost'} - onClick={() => setOpen(true)} + // onClick={() => setOpen(true)} // Original trigger + onClick={() => { // Temporarily disable direct opening, or let handleShare manage it + toast.info("Sharing functionality is currently disabled."); + }} > @@ -84,18 +95,20 @@ export function ChatShare({ chatId, className }: ChatShareProps) { {!shareUrl && ( - + // + )} {shareUrl && ( - + // + )} - + */}
) } diff --git a/components/history-item.tsx b/components/history-item.tsx index ea7a3b29..4040e3f8 100644 --- a/components/history-item.tsx +++ b/components/history-item.tsx @@ -3,11 +3,11 @@ import React from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Chat } from '@/lib/types' +import type { Chat as DrizzleChat } from '@/lib/actions/chat-db'; import { cn } from '@/lib/utils' type HistoryItemProps = { - chat: Chat + chat: DrizzleChat & { path: string }; } const formatDateWithTime = (date: Date | string) => { diff --git a/components/history-list.tsx b/components/history-list.tsx index 807e0403..5713bd2e 100644 --- a/components/history-list.tsx +++ b/components/history-list.tsx @@ -1,37 +1,81 @@ -import React, { cache } from 'react' -import HistoryItem from './history-item' -import { Chat } from '@/lib/types' -import { getChats } from '@/lib/actions/chat' -import { ClearHistory } from './clear-history' +import React, { cache } from 'react'; +import HistoryItem from './history-item'; +import { ClearHistory } from './clear-history'; +import { getChats } from '@/lib/actions/chat'; + +// Define the type for the chat data returned by getChats +type ChatData = { + userId: string; + id: string; + title: string; + createdAt: Date; + visibility: string | null; +}; + +// Define the Chat type expected by HistoryItem +type Chat = { + userId: string; + id: string; + title: string; + createdAt: Date; + visibility: string | null; + path: string; +}; type HistoryListProps = { - userId?: string -} + userId?: string; +}; -const loadChats = cache(async (userId?: string) => { - return await getChats(userId) -}) +const loadChats = cache(async (userId?: string): Promise => { + return await getChats(userId); +}); -// Start of Selection export async function HistoryList({ userId }: HistoryListProps) { - const chats = await loadChats(userId) + try { + const chats = await loadChats(userId); - return ( -
-
- {!chats?.length ? ( + if (!chats) { + return ( +
- No search history + Failed to load search history
- ) : ( - chats?.map( - (chat: Chat) => chat && - ) - )} +
+ ); + } + + return ( +
+
+ {!chats.length ? ( +
+ No search history +
+ ) : ( + chats.map((chat: ChatData) => ( + + )) + )} +
+
+ +
-
- + ); + } catch (error) { + console.error('Failed to load chats:', error); + return ( +
+
+ Error loading search history +
-
- ) + ); + } } diff --git a/components/history.tsx b/components/history.tsx index 1920d467..7cc8047b 100644 --- a/components/history.tsx +++ b/components/history.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button' import { ChevronLeft, Menu } from 'lucide-react' import { cn } from '@/lib/utils' import { History as HistoryIcon } from 'lucide-react' -import { HistoryList } from './history-list' +import { ChatHistoryClient } from './sidebar/chat-history-client' // Updated import import { Suspense } from 'react' import { HistorySkeleton } from './history-skelton' @@ -40,7 +40,7 @@ export function History({ location }: HistoryProps) {
}> - +
diff --git a/components/sidebar/chat-history-client.tsx b/components/sidebar/chat-history-client.tsx new file mode 100644 index 00000000..eeb959b3 --- /dev/null +++ b/components/sidebar/chat-history-client.tsx @@ -0,0 +1,161 @@ +'use client'; + +import React, { useEffect, useState, useTransition } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { Spinner } from '@/components/ui/spinner'; +import HistoryItem from '@/components/history-item'; // Adjust path if HistoryItem is moved or renamed +import type { Chat as DrizzleChat } from '@/lib/actions/chat-db'; // Use the Drizzle-based Chat type + +interface ChatHistoryClientProps { + // userId is no longer passed as prop; API route will use authenticated user +} + +export function ChatHistoryClient({}: ChatHistoryClientProps) { + const [chats, setChats] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isClearPending, startClearTransition] = useTransition(); + const [isAlertDialogOpen, setIsAlertDialogOpen] = useState(false); + const router = useRouter(); + + useEffect(() => { + async function fetchChats() { + setIsLoading(true); + setError(null); + try { + // API route /api/chats uses getCurrentUserId internally + const response = await fetch('/api/chats?limit=50&offset=0'); // Example limit/offset + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Failed to fetch chats: ${response.statusText}`); + } + const data: { chats: DrizzleChat[], nextOffset: number | null } = await response.json(); + setChats(data.chats); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + toast.error(`Error fetching chats: ${err.message}`); + } else { + setError('An unknown error occurred.'); + toast.error('Error fetching chats: An unknown error occurred.'); + } + } finally { + setIsLoading(false); + } + } + fetchChats(); + }, []); + + const handleClearHistory = async () => { + startClearTransition(async () => { + try { + // We need a new API endpoint for clearing history + // Example: DELETE /api/chats (or POST /api/clear-history) + // This endpoint will call clearHistory(userId) from chat-db.ts + const response = await fetch('/api/chats/all', { // Placeholder for the actual clear endpoint + method: 'DELETE', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to clear history'); + } + + toast.success('History cleared'); + setChats([]); // Clear chats from UI + setIsAlertDialogOpen(false); + router.refresh(); // Refresh to reflect changes, potentially redirect if on a chat page + // Consider redirecting to '/' if current page is a chat that got deleted. + // The old clearChats action did redirect('/'); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } else { + toast.error('An unknown error occurred while clearing history.'); + } + setIsAlertDialogOpen(false); + } + }); + }; + + if (isLoading) { + return ( +
+ +

Loading history...

+
+ ); + } + + if (error) { + // Optionally provide a retry button + return ( +
+

Error loading chat history: {error}

+
+ ); + } + + return ( +
+
+ {!chats?.length ? ( +
+ No search history +
+ ) : ( + chats.map((chat) => ( + // Assuming HistoryItem is adapted for DrizzleChat and expects chat.id and chat.title + // Also, chat.path will need to be constructed, e.g., `/search/${chat.id}` + + )) + )} +
+
+ + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + chat history. + + + + setIsAlertDialogOpen(false)}>Cancel + { + event.preventDefault(); + handleClearHistory(); + }} + > + {isClearPending ? : 'Clear'} + + + + +
+
+ ); +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..1a530f03 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,19 @@ +import type { Config } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: '.env.local' }); + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set'); +} + +export default { + schema: './lib/db/schema.ts', + out: './drizzle/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL, // Changed from connectionString to url + }, + verbose: true, + strict: true, +} satisfies Config; diff --git a/drizzle/migrations/0000_sweet_metal_master.sql b/drizzle/migrations/0000_sweet_metal_master.sql new file mode 100644 index 00000000..41df5dbf --- /dev/null +++ b/drizzle/migrations/0000_sweet_metal_master.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "chats" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "title" varchar(256) DEFAULT 'Untitled Chat' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "visibility" varchar(50) DEFAULT 'private' +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chat_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" varchar(50) NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/migrations/meta/0000_snapshot.json b/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..eb62145d --- /dev/null +++ b/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,165 @@ +{ + "id": "0d46923a-5423-4b73-91cb-5f46741e7ff9", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "5", + "dialect": "pg", + "tables": { + "chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Chat'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "visibility": { + "name": "visibility", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'private'" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_user_id_users_id_fk": { + "name": "chats_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json new file mode 100644 index 00000000..34cd1203 --- /dev/null +++ b/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "5", + "dialect": "pg", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1750358514791, + "tag": "0000_sweet_metal_master", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/install_bun.sh b/install_bun.sh old mode 100644 new mode 100755 diff --git a/lib/actions/chat-db.ts b/lib/actions/chat-db.ts new file mode 100644 index 00000000..4f0559ec --- /dev/null +++ b/lib/actions/chat-db.ts @@ -0,0 +1,223 @@ +import { db } from '@/lib/db'; +import { chats, messages, users } from '@/lib/db/schema'; +import { eq, desc, and, sql, asc } from 'drizzle-orm'; // Added asc +import { alias } from 'drizzle-orm/pg-core'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; // We'll use this to ensure user-specific actions + +// Define types based on our schema for better type safety +// These would ideally be generated by Drizzle Kit or defined in a central types location in a larger app +export type Chat = typeof chats.$inferSelect; +export type Message = typeof messages.$inferSelect; +export type User = typeof users.$inferSelect; +export type NewChat = typeof chats.$inferInsert; +export type NewMessage = typeof messages.$inferInsert; + +/** + * Retrieves a specific chat by its ID, ensuring it belongs to the current user + * or is public. + * @param id - The ID of the chat to retrieve. + * @param userId - The ID of the user requesting the chat. + * @returns The chat object if found and accessible, otherwise null. + */ +export async function getChat(id: string, userId: string): Promise { + if (!userId) { + console.warn('getChat called without userId'); + // Potentially allow fetching public chats if userId is null for anonymous users + const result = await db.select().from(chats).where(and(eq(chats.id, id), eq(chats.visibility, 'public'))).limit(1); + return result[0] || null; + } + + const result = await db.select() + .from(chats) + .where( + and( + eq(chats.id, id), + sql`${chats.userId} = ${userId} OR ${chats.visibility} = 'public'` + ) + ) + .limit(1); + return result[0] || null; +} + +/** + * Retrieves a paginated list of chats for a given user. + * @param userId - The ID of the user whose chats to retrieve. + * @param limit - The maximum number of chats to return. + * @param offset - The number of chats to skip (for pagination). + * @returns An object containing the list of chats and the next offset. + */ +export async function getChatsPage( + userId: string, + limit: number = 20, + offset: number = 0 +): Promise<{ chats: Chat[]; nextOffset: number | null }> { + if (!userId) { + console.error('getChatsPage called without userId.'); + return { chats: [], nextOffset: null }; + } + const result = await db + .select() + .from(chats) + .where(eq(chats.userId, userId)) + .orderBy(desc(chats.createdAt)) + .limit(limit) + .offset(offset); + + let nextOffset: number | null = null; + if (result.length === limit) { + nextOffset = offset + limit; + } + + return { chats: result, nextOffset }; +} + +/** + * Saves a chat and its messages. If the chat exists, it updates it. + * This function should handle both creating new chats and appending messages. + * The PR implies complex logic for saving, including message IDs. + * This is a simplified version; PR #533 might have more granular message saving. + * @param chatData - The chat data to save. + * @param messagesData - An array of messages to save with the chat. + * @returns The saved chat ID. + */ +export async function saveChat(chatData: NewChat, messagesData: Omit[]): Promise { + if (!chatData.userId) { + console.error('Cannot save chat without a userId'); + return null; + } + + // Transaction to ensure atomicity + return db.transaction(async (tx) => { + let chatId = chatData.id; + + if (chatId) { // If chat ID is provided, assume update or append messages + const existingChat = await tx.select({ id: chats.id }).from(chats).where(eq(chats.id, chatId)).limit(1); + if (!existingChat.length) { + // Chat doesn't exist, so create it + const newChatResult = await tx.insert(chats).values(chatData).returning({ id: chats.id }); + chatId = newChatResult[0].id; + } else { + // Optionally update chat metadata here if needed, e.g., title + if (chatData.title) { + await tx.update(chats).set({ title: chatData.title }).where(eq(chats.id, chatId)); + } + } + } else { // No chat ID, create new chat + const newChatResult = await tx.insert(chats).values(chatData).returning({ id: chats.id }); + chatId = newChatResult[0].id; + } + + if (!chatId) { + // console.error('Failed to establish chatId within transaction.'); // Optional: for server logs + throw new Error('Failed to establish chatId for chat operation.'); + } + + // Save messages + if (messagesData && messagesData.length > 0) { + const messagesToInsert = messagesData.map(msg => ({ + ...msg, + chatId: chatId!, // Ensure chatId is set for all messages + userId: msg.userId || chatData.userId!, // Ensure userId is set + })); + await tx.insert(messages).values(messagesToInsert); + } + return chatId; + }); +} + + +/** + * Creates a single message within a chat. + * PR #533 has commits like "feat: Add message update and trailing deletion logic", + * suggesting more granular message operations. This is a basic create. + * @param messageData - The message data to save. + * @returns The created message object or null if error. + */ +export async function createMessage(messageData: NewMessage): Promise { + if (!messageData.chatId || !messageData.userId || !messageData.role || !messageData.content) { + console.error('Missing required fields for creating a message.'); + return null; + } + try { + const result = await db.insert(messages).values(messageData).returning(); + return result[0] || null; + } catch (error) { + console.error('Error creating message:', error); + return null; + } +} + +/** + * Deletes a specific chat and its associated messages (due to cascade delete). + * @param id - The ID of the chat to delete. + * @param userId - The ID of the user requesting deletion, for authorization. + * @returns True if deletion was successful, false otherwise. + */ +export async function deleteChat(id: string, userId: string): Promise { + if (!userId) { + console.error('deleteChat called without userId.'); + return false; + } + try { + const result = await db + .delete(chats) + .where(and(eq(chats.id, id), eq(chats.userId, userId))) // Ensure user owns the chat + .returning({ id: chats.id }); + return result.length > 0; + } catch (error) { + console.error('Error deleting chat:', error); + return false; + } +} + +/** + * Clears the chat history for a given user (deletes all their chats). + * @param userId - The ID of the user whose chat history to clear. + * @returns True if history was cleared, false otherwise. + */ +export async function clearHistory(userId: string): Promise { + if (!userId) { + console.error('clearHistory called without userId.'); + return false; + } + try { + // This will also delete associated messages due to cascade delete constraint + await db.delete(chats).where(eq(chats.userId, userId)); + return true; + } catch (error) { + console.error('Error clearing history:', error); + return false; + } +} + +/** + * Retrieves all messages for a given chat ID, ordered by creation time. + * @param chatId - The ID of the chat whose messages to retrieve. + * @returns An array of message objects. + */ +export async function getMessagesByChatId(chatId: string): Promise { + if (!chatId) { + console.warn('getMessagesByChatId called without chatId'); + return []; + } + try { + const result = await db + .select() + .from(messages) + .where(eq(messages.chatId, chatId)) + .orderBy(asc(messages.createdAt)); // Order messages chronologically + return result; + } catch (error) { + console.error(`Error fetching messages for chat ${chatId}:`, error); + return []; + } +} + +// More granular functions might be needed based on PR #533 specifics: +// - updateMessage(messageId: string, updates: Partial): Promise +// - deleteMessage(messageId: string, userId: string): Promise +// - deleteTrailingMessages(chatId: string, lastKeptMessageId: string): Promise +// These are placeholders for now and can be implemented if subsequent steps show they are directly part of PR #533's changes. +// The PR mentions "feat: Add message update and trailing deletion logic" and "refactor(chat): Adjust message edit logic". + +console.log('Chat DB actions loaded. Ensure getCurrentUserId() is correctly implemented for server-side usage if applicable.'); diff --git a/lib/actions/chat.ts b/lib/actions/chat.ts index e7488f1b..2b1ec4d9 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -2,152 +2,219 @@ import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' -import { type Chat, type AIMessage } from '@/lib/types' // Added AIMessage -import { Redis } from '@upstash/redis' -// NOTE: nanoid is not used in this file, consider adding for truly unique IDs if that's a project pattern. -// For now, Date.now().toString() will be used for message IDs within updateDrawingContext. - -const redis = new Redis({ - url: process.env.UPSTASH_REDIS_REST_URL?.trim() || '', - token: process.env.UPSTASH_REDIS_REST_TOKEN || '' -}) - -export async function getChats(userId?: string | null) { +import { type Chat as OldChatType, type AIMessage } from '@/lib/types' // Added AIMessage, OldChatType for transition +import { + getChatsPage as dbGetChatsPage, + getChat as dbGetChat, + clearHistory as dbClearHistory, + saveChat as dbSaveChat, + createMessage as dbCreateMessage, + getMessagesByChatId as dbGetMessagesByChatId, // Added + type Chat as DrizzleChat, + type Message as DrizzleMessage, // Added + type NewChat as DbNewChat, + type NewMessage as DbNewMessage +} from '@/lib/actions/chat-db' +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' // For operations needing current user + +// TODO: Migrate Redis-based functions below (saveSystemPrompt, getSystemPrompt) if needed. +// const redis = new Redis({ +// url: process.env.UPSTASH_REDIS_REST_URL?.trim() || '', +// token: process.env.UPSTASH_REDIS_REST_TOKEN || '' +// }) + +export async function getChats(userId?: string | null): Promise { if (!userId) { + console.warn('getChats called without userId, returning empty array.') return [] } try { - const pipeline = redis.pipeline() - const chats: string[] = await redis.zrange(`user:chat:${userId}`, 0, -1, { - rev: true - }) - - for (const chat of chats) { - pipeline.hgetall(chat) - } - - const results = await pipeline.exec() - - return results as Chat[] + // Using a default limit and offset for now + const { chats } = await dbGetChatsPage(userId, 20, 0) + return chats } catch (error) { + console.error('Error fetching chats from DB:', error) return [] } } -export async function getChat(id: string, userId: string = 'anonymous') { - const chat = await redis.hgetall(`chat:${id}`) - - if (!chat) { +export async function getChat(id: string, userId: string): Promise { + // userId is now mandatory for dbGetChat to check ownership or public status + if (!userId) { + console.warn('getChat called without userId.') + // Optionally, could try to fetch only public chat if that's a use case + // return await dbGetChat(id, ''); // Pass empty or a specific marker for anonymous + return null; + } + try { + const chat = await dbGetChat(id, userId) + return chat + } catch (error) { + console.error(`Error fetching chat ${id} from DB:`, error) return null } - - return chat } -export async function clearChats( - userId: string = 'anonymous' -): Promise<{ error?: string }> { - const chats: string[] = await redis.zrange(`user:chat:${userId}`, 0, -1) - if (!chats.length) { - return { error: 'No chats to clear' } +/** + * Retrieves all messages for a specific chat. + * @param chatId The ID of the chat. + * @returns A promise that resolves to an array of DrizzleMessage objects. + */ +export async function getChatMessages(chatId: string): Promise { + if (!chatId) { + console.warn('getChatMessages called without chatId'); + return []; } - const pipeline = redis.pipeline() - - for (const chat of chats) { - pipeline.del(chat) - pipeline.zrem(`user:chat:${userId}`, chat) + try { + return dbGetMessagesByChatId(chatId); + } catch (error) { + console.error(`Error fetching messages for chat ${chatId} in getChatMessages:`, error); + return []; } - - await pipeline.exec() - - revalidatePath('/') - redirect('/') } -export async function saveChat(chat: Chat, userId: string = 'anonymous') { - const pipeline = redis.pipeline() - pipeline.hmset(`chat:${chat.id}`, chat) - pipeline.zadd(`user:chat:${chat.userId}`, { - score: Date.now(), - member: `chat:${chat.id}` - }) - await pipeline.exec() -} - -export async function getSharedChat(id: string) { - const chat = await redis.hgetall(`chat:${id}`) - - if (!chat || !chat.sharePath) { - return null +export async function clearChats( + userId?: string | null // Changed to optional, will try to get current user if not provided +): Promise<{ error?: string } | void> { // void for success + const currentUserId = userId || (await getCurrentUserIdOnServer()) + if (!currentUserId) { + console.error('clearChats: No user ID provided or found.') + return { error: 'User ID is required to clear chats' } } - return chat + try { + const success = await dbClearHistory(currentUserId) + if (!success) { + return { error: 'Failed to clear chats from database.' } + } + // Revalidation and redirect should ideally be handled by the caller (e.g., Server Action, API route) + // For now, keeping them as they were, but this makes the function less reusable. + revalidatePath('/') + redirect('/') + } catch (error) { + console.error('Error clearing chats from DB:', error) + return { error: 'Failed to clear chat history' } + } } -export async function shareChat(id: string, userId: string = 'anonymous') { - const chat = await redis.hgetall(`chat:${id}`) +export async function saveChat(chat: OldChatType, userId: string): Promise { + // This function now maps the old Chat type to new Drizzle types + // and calls the new dbSaveChat function. + if (!userId && !chat.userId) { + console.error('saveChat: userId is required either as a parameter or in chat object.') + return null; + } + const effectiveUserId = userId || chat.userId; + + const newChatData: DbNewChat = { + id: chat.id, // Keep existing ID if present (for updates) + userId: effectiveUserId, + title: chat.title || 'Untitled Chat', + createdAt: chat.createdAt ? new Date(chat.createdAt) : new Date(), // Ensure Date object + visibility: 'private', // Default or map from old chat if available + // sharePath: chat.sharePath, // sharePath is not in new schema by default + }; - if (!chat) { - return null - } + const newMessagesData: Omit[] = chat.messages.map(msg => ({ + id: msg.id, // Keep existing ID + userId: effectiveUserId, // Ensure messages have a userId + role: msg.role, // Allow all AIMessage roles to pass through + content: msg.content, + createdAt: msg.createdAt ? new Date(msg.createdAt) : new Date(), + // attachments: (msg as any).attachments, // If AIMessage had attachments + // type: (msg as any).type // If AIMessage had a type + })); - const payload = { - ...chat, - sharePath: `/share/${id}` + try { + const savedChatId = await dbSaveChat(newChatData, newMessagesData); + return savedChatId; + } catch (error) { + console.error('Error saving chat to DB:', error); + return null; } - - await redis.hmset(`chat:${id}`, payload) - - return payload } +// TODO: Re-evaluate sharing functionality with Supabase if needed. +// PR #533 removes the share page, so these are likely deprecated for now. +// export async function getSharedChat(id: string) { +// // This would need to be reimplemented using dbGetChat with public visibility logic +// // const chat = await dbGetChat(id, ''); // Need a way to signify public access +// // if (!chat || chat.visibility !== 'public') { // Assuming 'public' visibility for shared +// // return null; +// // } +// // return chat; +// console.warn("getSharedChat is deprecated and needs reimplementation with new DB structure."); +// return null; +// } + +// export async function shareChat(id: string, userId: string) { +// // This would involve updating a chat's visibility to 'public' in the DB +// // and potentially creating a unique share link if `sharePath` is not just derived. +// // const chat = await dbGetChat(id, userId); +// // if (!chat) { +// // return null; +// // } +// // // Update chat visibility to public +// // // const updatedChat = await db.update(chatsTable).set({ visibility: 'public' }).where(eq(chatsTable.id, id)).returning(); +// // // return updatedChat[0]; +// console.warn("shareChat is deprecated and needs reimplementation with new DB structure."); +// return null; +// } + export async function updateDrawingContext(chatId: string, drawnFeatures: any[]) { 'use server'; console.log('[Action] updateDrawingContext called for chatId:', chatId); - const chat = await getChat(chatId); // Assuming getChat can be called without userId for internal server actions - if (!chat) { - console.error('updateDrawingContext: Chat not found for id:', chatId); - return { error: 'Chat not found' }; - } - - // Ensure chat.userId exists, as saveChat expects it. - // getChat currently defaults userId to 'anonymous' but that's for retrieval, - // the actual chat object should have the original userId. - const userId = chat.userId; + const userId = await getCurrentUserIdOnServer(); // Essential for creating a user-associated message if (!userId) { - console.error('updateDrawingContext: userId not found in chat object for chatId:', chatId); - return { error: 'User ID not found in chat' }; + console.error('updateDrawingContext: Could not get current user ID. User must be authenticated.'); + return { error: 'User not authenticated' }; } - // Generate a new message ID (placeholder, consider using nanoid or similar for production) - const messageId = `drawnData-${Date.now().toString()}`; - - const newDrawingMessage: AIMessage = { - id: messageId, - role: 'data', // Using 'data' role for this system message + // The old version fetched the whole chat. Now we just create a new message. + // The AIMessage type might be from '@/lib/types' and need mapping to DbNewMessage + const newDrawingMessage: Omit = { + // id: `drawnData-${Date.now().toString()}`, // Let DB generate UUID + userId: userId, + role: 'data' as 'user' | 'assistant' | 'system' | 'tool' | 'data', // Cast 'data' if not in standard roles content: JSON.stringify(drawnFeatures), // Store features as stringified JSON - type: 'drawing_context', // Custom type for easy identification/filtering later - // name: 'drawing_update', // Optional: if you want to provide a name for the data event - createdAt: new Date(), // Optional: Add a timestamp for the message + // type: 'drawing_context', // This field is not in the Drizzle 'messages' schema. + // If `type` is important, the schema needs to be updated or content needs to reflect it. + // For now, we'll assume 'content' holds the necessary info and role='data' signifies it. + createdAt: new Date(), }; - const updatedMessages = [...chat.messages, newDrawingMessage]; - const updatedChat: Chat = { ...chat, messages: updatedMessages }; - try { - await saveChat(updatedChat, userId); // saveChat expects userId - console.log('Drawing context message added to chat:', chatId); - // Optionally, revalidate relevant paths if this change should immediately reflect elsewhere - // revalidatePath(`/search/${chatId}`); - return { success: true, messageId: newDrawingMessage.id }; + // We need to ensure the message is associated with the chat. + // dbCreateMessage requires chatId. + const messageToSave: DbNewMessage = { + ...newDrawingMessage, + chatId: chatId, + }; + const savedMessage = await dbCreateMessage(messageToSave); + if (!savedMessage) { + throw new Error('Failed to save drawing context message.'); + } + console.log('Drawing context message added to chat:', chatId, 'messageId:', savedMessage.id); + return { success: true, messageId: savedMessage.id }; } catch (error) { - console.error('updateDrawingContext: Error saving chat:', error); - return { error: 'Failed to save updated chat' }; + console.error('updateDrawingContext: Error saving drawing context message:', error); + return { error: 'Failed to save drawing context message' }; } } +// TODO: These Redis-based functions for system prompt need to be migrated +// if their functionality is still required and intended to use the new DB. +// For now, they are left as is, but will likely fail if Redis config is removed. +// @ts-ignore - Ignoring Redis import error for now as it might be removed or replaced +import { Redis } from '@upstash/redis'; // This will cause issues if REDIS_URL is not configured. +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL?.trim() || '', + token: process.env.UPSTASH_REDIS_REST_TOKEN || '' +}); + + export async function saveSystemPrompt( userId: string, prompt: string diff --git a/lib/auth/get-current-user.ts b/lib/auth/get-current-user.ts new file mode 100644 index 00000000..20c0b24b --- /dev/null +++ b/lib/auth/get-current-user.ts @@ -0,0 +1,81 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr'; +import { cookies } from 'next/headers'; +import type { User, Session } from '@supabase/supabase-js'; + +// Ensure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are available +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + +/** + * Retrieves the Supabase user and session object in server-side contexts + * (Route Handlers, Server Actions, Server Components). + * Uses '@supabase/ssr' for cookie-based session management. + * + * @returns {Promise<{ user: User | null; session: Session | null; error: any | null }>} + */ +export async function getSupabaseUserAndSessionOnServer(): Promise<{ + user: User | null; + session: Session | null; + error: any | null; +}> { + if (!supabaseUrl || !supabaseAnonKey) { + console.error('Supabase URL or Anon Key is not set for server-side auth.'); + return { user: null, session: null, error: new Error('Missing Supabase environment variables') }; + } + + const cookieStore = cookies(); + const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { + cookies: { + async get(name: string): Promise { + const cookie = (await cookieStore).get(name); // Use the correct get method + return cookie?.value; // Return the value or undefined + }, + async set(name: string, value: string, options: CookieOptions): Promise { + try { + const store = await cookieStore; + store.set({ name, value, ...options }); // Set cookie with options + } catch (error) { + console.warn(`Failed to set cookie ${name}:`, error); + } + }, + async remove(name: string, options: CookieOptions): Promise { + try { + const store = await cookieStore; + store.set({ name, value: '', ...options, maxAge: 0 }); // Delete cookie by setting maxAge to 0 + } catch (error) { + console.warn(`Failed to delete cookie ${name}:`, error); + } + }, + }, + }); + + const { + data: { session }, + error, + } = await supabase.auth.getSession(); + + if (error) { + console.error('Error getting Supabase session on server:', error.message); + return { user: null, session: null, error }; + } + + if (!session) { + return { user: null, session: null, error: null }; + } + + return { user: session.user, session, error: null }; +} + +/** + * Retrieves the current user's ID in server-side contexts. + * Wrapper around getSupabaseUserAndSessionOnServer. + * + * @returns {Promise} The user ID if a session exists, otherwise null. + */ +export async function getCurrentUserIdOnServer(): Promise { + const { user, error } = await getSupabaseUserAndSessionOnServer(); + if (error) { + return null; // Error already logged in getSupabaseUserAndSessionOnServer + } + return user?.id || null; +} \ No newline at end of file diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 00000000..0283d9a3 --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,25 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool, type PoolConfig } from 'pg'; // Uses Pool from pg, import PoolConfig +import * as dotenv from 'dotenv'; +import * as schema from './schema'; + +dotenv.config({ path: '.env.local' }); + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set for Drizzle client'); +} + +const poolConfig: PoolConfig = { + connectionString: process.env.DATABASE_URL, +}; + +// Conditionally apply SSL for Supabase URLs +if (process.env.DATABASE_URL && process.env.DATABASE_URL.includes('supabase.co')) { + poolConfig.ssl = { + rejectUnauthorized: false, + }; +} + +const pool = new Pool(poolConfig); + +export const db = drizzle(pool, { schema, logger: process.env.NODE_ENV === 'development' }); diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts new file mode 100644 index 00000000..7c696a4d --- /dev/null +++ b/lib/db/migrate.ts @@ -0,0 +1,41 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { Pool } from 'pg'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: '.env.local' }); + +async function runMigrations() { + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set for migrations'); + } + + const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: { + rejectUnauthorized: false, // Ensure this is appropriate for your Supabase connection + }, + // max: 1, // Optional: restrict to 1 connection for migration + }); + + const db = drizzle(pool); + + console.log('Running database migrations...'); + try { + // Point to the directory containing your migration files + await migrate(db, { migrationsFolder: './drizzle/migrations' }); + console.log('Migrations completed successfully.'); + } catch (error) { + console.error('Error running migrations:', error); + process.exit(1); // Exit with error code + } finally { + await pool.end(); // Ensure the connection pool is closed + } +} + +if (process.env.EXECUTE_MIGRATIONS === 'true') { + runMigrations(); +} else { + console.log('Skipping migrations. Set EXECUTE_MIGRATIONS=true to run them.'); + console.log('To run migrations, use the "npm run db:migrate" or "bun run db:migrate" script, which sets this variable.'); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 00000000..870339a0 --- /dev/null +++ b/lib/db/schema.ts @@ -0,0 +1,62 @@ +import { pgTable, text, timestamp, uuid, varchar, jsonb, boolean } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// Users Table (assuming Supabase Auth uses its own users table, +// but a local reference might be used or this could be a public profile table) +// For now, let's assume a simple users table if PR #533 implies one in schema.ts +// If PR #533 relies purely on Supabase Auth's user IDs without a separate 'users' table managed by Drizzle for chat context, +// then this table might be simpler or not needed. Given the PR title focuses on chat migration, +// we'll include a basic one that can be referenced by chats and messages. +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), // Assuming Supabase user IDs are UUIDs + // email: text('email'), // Supabase handles this in auth.users + // Other profile fields if necessary +}); + +export const chats = pgTable('chats', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), // References a user ID + title: varchar('title', { length: 256 }).notNull().default('Untitled Chat'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + // RLS in Supabase will use policies, but marking public visibility can be a column + visibility: varchar('visibility', { length: 50 }).default('private'), // e.g., 'private', 'public' + // any other metadata for the chat +}); + +export const messages = pgTable('messages', { + id: uuid('id').primaryKey().defaultRandom(), + chatId: uuid('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), // Who sent the message + role: varchar('role', { length: 50 }).notNull(), // e.g., 'user', 'assistant', 'system', 'tool' + content: text('content').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + // attachments: jsonb('attachments'), // As per PR commit: "feat: remove updatedAt and add attachments field to messages" + // toolName: varchar('tool_name', { length: 100 }), // If messages can be from tools + // toolCallId: varchar('tool_call_id', {length: 100}), // if tracking specific tool calls + // type: varchar('type', { length: 50 }) // As per app/actions.tsx AIMessage type +}); + +// Relations +export const usersRelations = relations(users, ({ many }) => ({ + chats: many(chats), + messages: many(messages), +})); + +export const chatsRelations = relations(chats, ({ one, many }) => ({ + user: one(users, { + fields: [chats.userId], + references: [users.id], + }), + messages: many(messages), +})); + +export const messagesRelations = relations(messages, ({ one }) => ({ + chat: one(chats, { + fields: [messages.chatId], + references: [chats.id], + }), + user: one(users, { + fields: [messages.userId], + references: [users.id], + }), +})); diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts new file mode 100644 index 00000000..4d6bf33a --- /dev/null +++ b/lib/supabase/client.ts @@ -0,0 +1,41 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + +if (!supabaseUrl) { + throw new Error('NEXT_PUBLIC_SUPABASE_URL environment variable is not set.'); +} +if (!supabaseAnonKey) { + throw new Error('NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable is not set.'); +} + +// Supabase client for client-side usage (e.g., in React components) +// This client uses the public anon key. +export const supabase = createClient(supabaseUrl, supabaseAnonKey); + +// It's generally recommended to handle server-side Supabase operations +// (like those requiring service_role or auth admin tasks) in dedicated server-side modules or API routes. +// If you need a server-side client for specific auth-related tasks using the service role key, +// it should be initialized carefully and only used in secure server environments. +// For example, a function to get a service role client: +// import { SupabaseClient } from '@supabase/supabase-js'; +// let _serviceRoleClient: SupabaseClient | null = null; +// export const getSupabaseServiceRoleClient = (): SupabaseClient => { +// if (_serviceRoleClient) return _serviceRoleClient; +// const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; +// if (!serviceKey) { +// throw new Error('SUPABASE_SERVICE_ROLE_KEY environment variable is not set.'); +// } +// _serviceRoleClient = createClient(supabaseUrl, serviceKey, { +// auth: { +// autoRefreshToken: false, +// persistSession: false, +// }, +// }); +// return _serviceRoleClient; +// }; +// However, for many server-side Next.js operations (like in Route Handlers or Server Actions), +// you might use the Supabase Server Client (@supabase/ssr) which is designed for Next.js and handles sessions. +// For now, the PR seems to focus on Drizzle for DB and basic Supabase client for auth interactions. +// We will stick to the basic client and can expand if @supabase/ssr is intended by PR #533. diff --git a/lib/utils/index.ts b/lib/utils/index.ts index d441a96e..8b1a5e6b 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -6,10 +6,16 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createAnthropic } from '@ai-sdk/anthropic' import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' import { createXai } from '@ai-sdk/xai'; +import { v4 as uuidv4 } from 'uuid'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function generateUUID(): string { + return uuidv4(); +} + export function getModel() { const xaiApiKey = process.env.XAI_API_KEY const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID diff --git a/package.json b/package.json index b881fe58..be19b579 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev --turbo", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "db:migrate": "cross-env EXECUTE_MIGRATIONS=true bun lib/db/migrate.ts" }, "dependencies": { "@ai-sdk/amazon-bedrock": "^1.1.6", @@ -36,14 +37,22 @@ "@radix-ui/react-tooltip": "^1.2.3", "@smithery/cli": "^1.2.5", "@smithery/sdk": "^1.0.4", + "@supabase/ssr": "^0.3.0", + "@supabase/supabase-js": "^2.0.0", "@tailwindcss/typography": "^0.5.16", "@turf/turf": "^7.2.0", "@types/mapbox__mapbox-gl-draw": "^1.4.8", + "@types/pg": "^8.15.4", "@upstash/redis": "^1.35.0", + "QCX": ".", "ai": "^4.3.16", "build": "^0.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookie": "^0.6.0", + "dotenv": "^16.0.0", + "drizzle-kit": "^0.31.1", + "drizzle-orm": "^0.29.0", "embla-carousel-react": "^8.6.0", "exa-js": "^1.6.13", "framer-motion": "^12.15.0", @@ -54,10 +63,10 @@ "next": "^15.3.3", "next-themes": "^0.3.0", "open-codex": "^0.1.30", - "QCX": ".", + "pg": "^8.16.2", "radix-ui": "^1.3.4", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", "react-hook-form": "^7.56.2", "react-icons": "^5.5.0", "react-markdown": "^9.1.0", @@ -69,13 +78,17 @@ "remark-math": "^6.0.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^9.0.0" }, "devDependencies": { + "@types/cookie": "^0.6.0", "@types/mapbox-gl": "^3.4.1", "@types/node": "^20.17.30", - "@types/react": "^19.1.2", - "@types/react-dom": "^18.3.6", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/uuid": "^9.0.0", + "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-next": "^14.2.28", "postcss": "^8.5.3",