Files
auto_rfp/components/projects/ProjectIndexSelector.tsx
T
roland-the-engineer a9431b3ba0 Refactor environment variable management with centralized validation (#45)
* Refactor environment variable management with centralized validation

- Replace plain object with Map-based env structure
- Add all environment variables (not just LlamaCloud ones)
- Use sentinel value 'mandatory-env-not-set' for required vars
- Add helper functions: isDevelopment(), isProduction(), logEnv()
- Create instrumentation.ts for one-time startup validation
- Validate environment once at server startup using Next.js hook
- Exit with error code 1 if validation fails

This ensures the app doesn't start with missing required env vars and
provides better error messages during startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Update services to use centralized env.get() pattern

Replace direct process.env access with env.get() in:
- OpenAI services (openai-question-extractor, multi-step-response)
- Supabase utilities (4 files)
- Database client (db.ts)
- Login actions and API routes

Also use helper functions:
- isDevelopment() and isProduction() instead of NODE_ENV checks
- Keep Vercel platform vars (VERCEL_URL, VERCEL_ENV) as process.env

Remove constructor-level env validation checks since validation now
happens once at startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove redundant validateEnv() calls from services

Remove 9 validateEnv() calls across:
- lib/llama-index-service.ts
- lib/llamaparse-service.ts
- lib/services/llamacloud-documents-service.ts
- lib/services/llamacloud-connection-service.ts
- app/api/llamacloud/projects/route.ts
- app/api/organizations/route.ts

Environment validation now happens once at startup via instrumentation.ts,
so these per-request checks are no longer needed.

Also updated env access to use env.get() pattern consistently.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Simplify next.config.ts by removing explicit env var exposure

Remove explicit env var configuration from next.config.ts.
Next.js automatically makes NEXT_PUBLIC_* variables available to the
client, so explicit exposure is unnecessary.

Keeps only essential config:
- reactStrictMode
- output: 'standalone' (for Docker deployment)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix remaining env.PROPERTY to env.get() conversions

Updated remaining files that were still using env.PROPERTY pattern:
- lib/llama-index-service.ts
- lib/llamaparse-service.ts
- lib/services/llamacloud-client.ts
- lib/services/llamacloud-documents-service.ts
- app/api/llamacloud/projects/route.ts
- app/api/organizations/route.ts
- app/api/projects/[projectId]/indexes/route.ts

All files now consistently use env.get('VARIABLE_NAME')! pattern.
TypeScript compilation now passes successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor LlamaIndexService import paths and consolidate service files

* Fix ESLint warnings in React components

Fix React Hook dependency warnings:
- ProjectDocuments: Add fetchProjectDocuments to useEffect deps
- ProjectIndexSelector: Wrap hasChanges and handleSave in useCallback
  and add all dependencies to the debounced auto-save useEffect

All ESLint checks now pass with no warnings or errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add comprehensive environment variable documentation

Add detailed documentation explaining build-time vs runtime variables,
especially for NEXT_PUBLIC_* variables and Docker deployments.

Key additions:
- New "Environment Variables" section in README with comprehensive guide
- Explanation of build-time (NEXT_PUBLIC_*) vs runtime variables
- Docker deployment workflow (correct vs incorrect approaches)
- Why NEXT_PUBLIC_* variables require Docker rebuild
- Best practices for multi-environment deployments
- Code examples showing proper env.get() usage
- Startup validation explanation

This documentation is critical for users deploying with Docker, as
changing NEXT_PUBLIC_* variables without rebuilding will not work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Implement auth for all api routes

* NODE_ENV has a default

* Make the env lookup type-safe.

* Restrict API health check path in session update logic

* Improve UI consistency

---------

Co-authored-by: Roland Tritsch <roland@tritsch.email>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 15:41:46 -06:00

330 lines
11 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/ui/use-toast';
import {
Database,
Settings,
Check,
AlertCircle,
RefreshCw,
FolderOpen
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProjectIndex {
id: string;
name: string;
description?: string;
created_at?: string;
}
interface ProjectIndexSelectorProps {
projectId: string;
onSaveSuccess?: () => void;
}
export function ProjectIndexSelector({ projectId, onSaveSuccess }: ProjectIndexSelectorProps) {
const [currentIndexes, setCurrentIndexes] = useState<ProjectIndex[]>([]);
const [availableIndexes, setAvailableIndexes] = useState<ProjectIndex[]>([]);
const [selectedIndexId, setSelectedIndexId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [organizationConnected, setOrganizationConnected] = useState(false);
const [organizationName, setOrganizationName] = useState('');
const [llamaCloudProjectName, setLlamaCloudProjectName] = useState('');
const [isInitialized, setIsInitialized] = useState(false);
const { toast } = useToast();
const fetchProjectIndexes = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(`/api/projects/${projectId}/indexes`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch project indexes');
}
const data = await response.json();
setCurrentIndexes(data.currentIndexes || []);
setAvailableIndexes(data.availableIndexes || []);
// Take first index if any exist (single select)
const currentIds = data.currentIndexes?.map((index: ProjectIndex) => index.id) || [];
setSelectedIndexId(currentIds.length > 0 ? currentIds[0] : null);
setOrganizationConnected(data.organizationConnected);
setOrganizationName(data.organizationName || '');
setLlamaCloudProjectName(data.llamaCloudProjectName || '');
setIsInitialized(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch project indexes';
setError(errorMessage);
console.error('Error fetching project indexes:', err);
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchProjectIndexes();
}, [fetchProjectIndexes]);
const handleIndexSelect = (indexId: string) => {
if (isSaving) return;
// Toggle: if already selected, deselect; otherwise select this one
setSelectedIndexId(prev => prev === indexId ? null : indexId);
};
const hasChanges = useCallback(() => {
const currentId = currentIndexes.length > 0 ? currentIndexes[0].id : null;
return selectedIndexId !== currentId;
}, [currentIndexes, selectedIndexId]);
const handleSave = useCallback(async () => {
try {
setIsSaving(true);
const response = await fetch(`/api/projects/${projectId}/indexes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
indexIds: selectedIndexId ? [selectedIndexId] : [],
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update project indexes');
}
const data = await response.json();
setCurrentIndexes(data.projectIndexes || []);
// Notify parent that save succeeded
onSaveSuccess?.();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update project indexes';
toast({
title: 'Error',
description: errorMessage,
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
}, [projectId, selectedIndexId, onSaveSuccess, toast]);
// Debounced auto-save effect
useEffect(() => {
// Don't save on initial load or while loading
if (!isInitialized || isLoading) return;
// Only save if there are actual changes
if (!hasChanges()) return;
const timer = setTimeout(() => {
handleSave();
}, 800);
return () => clearTimeout(timer);
}, [selectedIndexId, isInitialized, isLoading, hasChanges, handleSave]);
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
<Skeleton className="h-5 w-32" />
</CardTitle>
<CardDescription>
<Skeleton className="h-4 w-64" />
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-3">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-48" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!organizationConnected) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Project Index
</CardTitle>
<CardDescription>
Select an index for this project
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<AlertCircle className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium mb-2">No LlamaCloud Connection</h3>
<p className="text-muted-foreground mb-4">
Your organization needs to be connected to LlamaCloud to select an index for this project.
</p>
<p className="text-sm text-muted-foreground">
Ask your organization admin to connect to LlamaCloud in the organization settings.
</p>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Project Index
</CardTitle>
<CardDescription>
Select an index for this project
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<AlertCircle className="mx-auto h-8 w-8 text-red-500 mb-3" />
<h3 className="text-lg font-medium text-red-900 mb-2">Error Loading Indexes</h3>
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" onClick={fetchProjectIndexes}>
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Project Index
</CardTitle>
<CardDescription>
Select an index from {organizationName}&apos;s LlamaCloud project &quot;{llamaCloudProjectName}&quot;
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{availableIndexes.length === 0 ? (
<div className="text-center py-6">
<FolderOpen className="mx-auto h-8 w-8 text-muted-foreground mb-3" />
<h3 className="text-lg font-medium mb-2">No Indexes Available</h3>
<p className="text-muted-foreground">
No indexes were found in your organization&apos;s LlamaCloud project.
</p>
</div>
) : (
<>
<div className="space-y-3">
{availableIndexes.map((index) => {
const isSelected = selectedIndexId === index.id;
return (
<div
key={index.id}
onClick={() => handleIndexSelect(index.id)}
className={cn(
"flex items-start space-x-3 p-3 border rounded-lg transition-colors cursor-pointer",
isSelected
? "border-blue-500 bg-blue-50"
: "hover:bg-muted/50",
isSaving && "opacity-50 pointer-events-none"
)}
>
{/* Radio-style indicator */}
<div className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
isSelected ? "border-blue-500 bg-blue-500" : "border-gray-300"
)}>
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="block font-medium">
{index.name}
</div>
{index.description && (
<p className="text-sm text-muted-foreground mt-1">
{index.description}
</p>
)}
{index.created_at && (
<p className="text-xs text-muted-foreground mt-1">
Created {new Date(index.created_at).toLocaleDateString()}
</p>
)}
</div>
{isSelected && (
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
<Check className="mr-1 h-3 w-3" />
Active
</Badge>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="text-sm text-muted-foreground">
{selectedIndexId
? `Selected: ${availableIndexes.find(i => i.id === selectedIndexId)?.name}`
: "No index selected"}
</div>
<div className="flex items-center gap-2">
{isSaving && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<Settings className="h-3 w-3 animate-spin" />
Saving...
</span>
)}
<Button
variant="outline"
size="sm"
onClick={fetchProjectIndexes}
disabled={isSaving}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}