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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
411 changes: 91 additions & 320 deletions app/actions.tsx

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions app/actions.tsx.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<<<<<<< SEARCH
const action = formData?.get('action') as string;
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
console.error('Failed to parse drawnFeatures:', e);
}
=======
const action = formData?.get('action') as string;
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
console.error('Failed to parse drawnFeatures:', e);
}

const chatId = aiState.get().chatId;
if (chatId && drawnFeatures.length === 0) {
const drawingContext = await getDrawingContext(chatId);
if (drawingContext) {
drawnFeatures = (drawingContext as any).drawnFeatures || [];
}
}
>>>>>>> REPLACE
100 changes: 100 additions & 0 deletions app/actions.tsx.tmp
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const action = formData?.get("action") as string; const drawnFeaturesString = formData?.get("drawnFeatures") as string; let drawnFeatures: DrawnFeature[] = []; try { drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : []; } catch (e) { console.error("Failed to parse drawnFeatures:", e); } const chatId = aiState.get().chatId; if (chatId && drawnFeatures.length === 0) { const drawingContext = await getDrawingContext(chatId); if (drawingContext) { drawnFeatures = (drawingContext as any).drawnFeatures || []; } }
console.error('Failed to parse drawnFeatures:', e);
}

if (action === 'generate_report_context') {
const messagesString = formData?.get('messages');
if (typeof messagesString !== 'string') {
return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' };
}
try {
const messages = JSON.parse(messagesString) as AIMessage[];
return await generateReportContext(messages);
} catch (e) {
console.error('Failed to parse messages for report context:', e);
return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' };
}
}

if (action === 'resolution_search') {
const file_mapbox = formData?.get('file_mapbox') as File;
const file_google = formData?.get('file_google') as File;
const file = (formData?.get('file') as File) || file_mapbox || file_google;
const timezone = (formData?.get('timezone') as string) || 'UTC';
const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;

if (!file) {
throw new Error('No file provided for resolution search.');
}

const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;

const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;

const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;

const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
(message: any) =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
);

const userInput = 'Analyze this map view.';
const content: CoreMessage['content'] = [
{ type: 'text', text: userInput },
{ type: 'image', image: dataUrl, mimeType: file.type }
];

aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
messages.push({ role: 'user', content });

const summaryStream = createStreamableValue<string>('Analyzing map view...');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
fullSummary = partialObject.summary;
summaryStream.update(fullSummary);
}
}

const analysisResult = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

// Reconstruct standard GeoJSON from flattened schema if present
let geoJson: FeatureCollection | null = null;
if (analysisResult.geoJson && analysisResult.geoJson.features) {
geoJson = {
type: 'FeatureCollection',
features: analysisResult.geoJson.features.map(f => ({
type: 'Feature',
geometry: {
type: f.geometryType as any,
coordinates: f.coordinates as any
},
properties: {
name: f.name,
description: f.description
}
}))
};
}
2 changes: 1 addition & 1 deletion components/history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function HistoryList({ userId }: HistoryListProps) {
key={chat.id}
chat={{
...chat,
path: chat.path || `/search/${chat.id}`,
path: `/search/${chat.id}`,
}}
/>
))
Expand Down
2 changes: 1 addition & 1 deletion components/history-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { History as HistoryIcon } from 'lucide-react'
import { ChatHistoryClient } from './sidebar/chat-history-client'
import ChatHistoryClient from './sidebar/chat-history-client'
import { Suspense } from 'react'
import { HistorySkeleton } from './history-skelton'
import { useHistoryToggle } from './history-toggle-context'
Expand Down
47 changes: 16 additions & 31 deletions components/sidebar/chat-history-client.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';
'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 React, { useState, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
Expand All @@ -14,22 +14,16 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { Spinner } from '@/components/ui/spinner';
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { useHistoryToggle } from '../history-toggle-context';
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
}
import { Zap, ChevronUp, ChevronDown } from 'lucide-react';
import { useHistoryToggle } from '@/components/history-toggle-context';
import type { Chat as DrizzleChat } from '@/lib/actions/chat-db';
import HistoryItem from '@/components/history-item';

export function ChatHistoryClient({}: ChatHistoryClientProps) {
export default function ChatHistoryClient() {
const [chats, setChats] = useState<DrizzleChat[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
Comment on lines +24 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid the empty-state flash on first open.

Line 26 now starts with isLoading = false, so opening the sheet first renders No search history before fetchChats() switches to the spinner. That shows incorrect content on the initial open.

Suggested fix
-  const [isLoading, setIsLoading] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function ChatHistoryClient() {
const [chats, setChats] = useState<DrizzleChat[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
export default function ChatHistoryClient() {
const [chats, setChats] = useState<DrizzleChat[]>([]);
const [isLoading, setIsLoading] = useState(true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/sidebar/chat-history-client.tsx` around lines 24 - 26, The initial
loading state in ChatHistoryClient is causing a brief empty-state flash before
fetchChats() runs. Initialize the loading state so the first render shows the
spinner instead of “No search history,” and make sure fetchChats() sets and
clears isLoading around the async load path used when the sheet opens.

const [error, setError] = useState<string | null>(null);
const [isClearPending, startClearTransition] = useTransition();
const [isAlertDialogOpen, setIsAlertDialogOpen] = useState(false);
Expand All @@ -42,8 +36,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
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
const response = await fetch('/api/chats?limit=50&offset=0');
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to fetch chats: ${response.statusText}`);
Expand Down Expand Up @@ -71,10 +64,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
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
const response = await fetch('/api/chats/all', {
method: 'DELETE',
});

Expand All @@ -84,11 +74,9 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
}

toast.success('History cleared');
setChats([]); // Clear chats from UI
setChats([]);
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('/');
router.refresh();
} catch (err) {
if (err instanceof Error) {
toast.error(err.message);
Expand All @@ -110,7 +98,6 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
}

if (error) {
// Optionally provide a retry button
return (
<div className="flex flex-col flex-1 space-y-3 h-full items-center justify-center text-destructive">
<p>Error loading chat history: {error}</p>
Expand Down Expand Up @@ -155,8 +142,6 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
</div>
) : (
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}`
<HistoryItem key={chat.id} chat={{...chat, path: `/search/${chat.id}`}} />
))
)}
Expand Down
42 changes: 0 additions & 42 deletions drizzle/migrations/0001_add_calendar_notes.sql

This file was deleted.

17 changes: 17 additions & 0 deletions drizzle/migrations/0001_sync_schema_full.sql
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,20 @@ ALTER TABLE "visualizations" ADD CONSTRAINT "visualizations_chat_id_chats_id_fk"
ALTER TABLE "messages" ADD CONSTRAINT "messages_location_id_locations_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."locations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chats" ADD CONSTRAINT "chats_shareable_link_id_unique" UNIQUE("shareable_link_id");--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE("email");

-- Add RLS for calendar_notes (extracted from removed duplicate migration)
ALTER TABLE "calendar_notes" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "user_select_own_notes" ON "calendar_notes" FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "user_insert_own_notes" ON "calendar_notes" FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "user_update_own_notes" ON "calendar_notes" FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "user_delete_own_notes" ON "calendar_notes" FOR DELETE USING (auth.uid() = user_id);

-- Add RLS for system_prompts
ALTER TABLE "system_prompts" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "user_select_own_system_prompts" ON "system_prompts" FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "user_insert_own_system_prompts" ON "system_prompts" FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "user_update_own_system_prompts" ON "system_prompts" FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "user_delete_own_system_prompts" ON "system_prompts" FOR DELETE USING (auth.uid() = user_id);

-- Add RLS for chat_contexts (newly added in 0007, but including here for consistency if needed,
-- or we can just ensure 0007 has it. Let's add it to 0007).
22 changes: 22 additions & 0 deletions drizzle/migrations/0003_consolidate_system_prompt.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Ensure system_prompts has a unique constraint on user_id
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'system_prompts_user_id_unique'
) THEN
ALTER TABLE "system_prompts" ADD CONSTRAINT "system_prompts_user_id_unique" UNIQUE("user_id");
END IF;
Comment on lines +4 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Deduplicate before adding the unique constraint.

Line 9 can fail if the existing unconstrained system_prompts table already has more than one row for a user. Collapse duplicates first, then add system_prompts_user_id_unique, so the migration is safe before the ON CONFLICT ("user_id") backfill runs.

Proposed migration hardening
 -- Ensure system_prompts has a unique constraint on user_id
+DELETE FROM "system_prompts" sp
+USING (
+    SELECT
+        "id",
+        ROW_NUMBER() OVER (
+            PARTITION BY "user_id"
+            ORDER BY "updated_at" DESC, "created_at" DESC, "id"
+        ) AS rn
+    FROM "system_prompts"
+) ranked
+WHERE sp."id" = ranked."id"
+  AND ranked.rn > 1;
+
 DO $$
 BEGIN
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@drizzle/migrations/0003_consolidate_system_prompt.sql` around lines 4 - 10,
The migration adding system_prompts_user_id_unique can fail on existing
duplicate system_prompts rows, so harden the migration by deduplicating rows per
user_id before the ALTER TABLE in the consolidation step. Update the migration
logic around the system_prompts unique constraint addition to collapse existing
duplicates first, then add the constraint, so the later ON CONFLICT ("user_id")
backfill path can run safely.

END $$;

-- Backfill existing users.system_prompt values into system_prompts
INSERT INTO "system_prompts" ("user_id", "prompt", "updated_at")
SELECT "id", "system_prompt", NOW()
FROM "users"
WHERE "system_prompt" IS NOT NULL
ON CONFLICT ("user_id") DO UPDATE
SET "prompt" = EXCLUDED."prompt", "updated_at" = EXCLUDED."updated_at";

-- Drop the users.system_prompt column
ALTER TABLE "users" DROP COLUMN IF EXISTS "system_prompt";
18 changes: 18 additions & 0 deletions drizzle/migrations/0004_remove_redundant_spatial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Convert existing geojson values into geometry for locations
UPDATE "locations"
SET "geometry" = ST_GeomFromGeoJSON("geojson"::text)
WHERE "geojson" IS NOT NULL AND "geometry" IS NULL;

-- Convert existing data values into geometry for visualizations (if they contain geometry)
UPDATE "visualizations"
SET "geometry" = ST_GeomFromGeoJSON("data"->>'geometry')
WHERE "data" IS NOT NULL AND "data"->>'geometry' IS NOT NULL AND "geometry" IS NULL;

-- Drop redundant column from locations
ALTER TABLE "locations" DROP COLUMN IF EXISTS "geojson";

-- Clean up redundant geometry overlap in visualizations.data
-- We keep the data column but remove the geometry key from the jsonb object
UPDATE "visualizations"
SET "data" = "data" - 'geometry'
WHERE "data" IS NOT NULL AND "data" ? 'geometry';
3 changes: 3 additions & 0 deletions drizzle/migrations/0005_drop_chat_paths.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Drop derived path and share_path columns from chats
ALTER TABLE "chats" DROP COLUMN IF EXISTS "path";
ALTER TABLE "chats" DROP COLUMN IF EXISTS "share_path";
4 changes: 4 additions & 0 deletions drizzle/migrations/0006_remove_synthetic_calendar_notes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Delete existing synthetic calendar-note rows from messages
DELETE FROM "messages"
WHERE "role" = 'data'
AND "content"::jsonb->>'type' = 'calendar_note';
Loading