feat: complete tRPC integration and fix platform UI issues

- Implement comprehensive tRPC setup with type-safe API
- Create tRPC routers for dashboard, admin, and auth endpoints
- Migrate frontend components to use tRPC client
- Fix platform dashboard Settings button functionality
- Add platform settings page with profile and security management
- Create OpenAI API mocking infrastructure for cost-safe testing
- Update tests to work with new tRPC architecture
- Sync database schema to fix AIBatchRequest table errors
This commit is contained in:
2025-07-11 15:37:53 +02:00
committed by Kaj Kowalski
parent f2a3d87636
commit fa7e815a3b
38 changed files with 4285 additions and 518 deletions

View File

@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import "leaflet/dist/leaflet.css";
import * as countryCoder from "@rapideditor/country-coder";
@ -22,7 +22,9 @@ interface GeographicMapProps {
* Get coordinates for a country using the country-coder library
* This automatically extracts coordinates from the country geometry
*/
function getCoordinatesFromCountryCoder(countryCode: string): [number, number] | undefined {
function getCoordinatesFromCountryCoder(
countryCode: string
): [number, number] | undefined {
try {
const feature = countryCoder.feature(countryCode);
if (!feature?.geometry) {
@ -35,7 +37,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
return [lat, lon]; // Leaflet expects [lat, lon]
}
if (feature.geometry.type === "Polygon" && feature.geometry.coordinates?.[0]?.[0]) {
if (
feature.geometry.type === "Polygon" &&
feature.geometry.coordinates?.[0]?.[0]
) {
// For polygons, calculate centroid from the first ring
const coordinates = feature.geometry.coordinates[0];
let lat = 0;
@ -47,7 +52,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
return [lat / coordinates.length, lon / coordinates.length];
}
if (feature.geometry.type === "MultiPolygon" && feature.geometry.coordinates?.[0]?.[0]?.[0]) {
if (
feature.geometry.type === "MultiPolygon" &&
feature.geometry.coordinates?.[0]?.[0]?.[0]
) {
// For multipolygons, use the first polygon's first ring for centroid
const coordinates = feature.geometry.coordinates[0][0];
let lat = 0;
@ -61,7 +69,10 @@ function getCoordinatesFromCountryCoder(countryCode: string): [number, number] |
return undefined;
} catch (error) {
console.warn(`Failed to get coordinates for country ${countryCode}:`, error);
console.warn(
`Failed to get coordinates for country ${countryCode}:`,
error
);
return undefined;
}
}
@ -90,7 +101,6 @@ export default function GeographicMap({
setIsClient(true);
}, []);
/**
* Get coordinates for a country code
*/
@ -129,22 +139,25 @@ export default function GeographicMap({
/**
* Process all countries data into CountryData array
*/
const processCountriesData = useCallback((
countries: Record<string, number>,
countryCoordinates: Record<string, [number, number]>
): CountryData[] => {
const data = Object.entries(countries || {})
.map(([code, count]) =>
processCountryEntry(code, count, countryCoordinates)
)
.filter((item): item is CountryData => item !== null);
const processCountriesData = useCallback(
(
countries: Record<string, number>,
countryCoordinates: Record<string, [number, number]>
): CountryData[] => {
const data = Object.entries(countries || {})
.map(([code, count]) =>
processCountryEntry(code, count, countryCoordinates)
)
.filter((item): item is CountryData => item !== null);
console.log(
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
);
console.log(
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
);
return data;
}, []);
return data;
},
[]
);
// Process country data when client is ready and dependencies change
useEffect(() => {

View File

@ -71,8 +71,7 @@ export default function MessageViewer({ messages }: MessageViewerProps) {
: "No timestamp"}
</span>
<span>
Last message:{" "}
{(() => {
Last message: {(() => {
const lastMessage = messages[messages.length - 1];
return lastMessage.timestamp
? new Date(lastMessage.timestamp).toLocaleString()

View File

@ -0,0 +1,253 @@
/**
* tRPC Demo Component
*
* This component demonstrates how to use tRPC hooks for queries and mutations.
* Can be used as a reference for migrating existing components.
*/
"use client";
import { Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { trpc } from "@/lib/trpc-client";
export function TRPCDemo() {
const [sessionFilters, setSessionFilters] = useState({
search: "",
page: 1,
limit: 5,
});
// Queries
const {
data: sessions,
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = trpc.dashboard.getSessions.useQuery(sessionFilters);
const { data: overview, isLoading: overviewLoading } =
trpc.dashboard.getOverview.useQuery({});
const { data: topQuestions, isLoading: questionsLoading } =
trpc.dashboard.getTopQuestions.useQuery({ limit: 3 });
// Mutations
const refreshSessionsMutation = trpc.dashboard.refreshSessions.useMutation({
onSuccess: (data) => {
toast.success(data.message);
// Invalidate and refetch sessions
refetchSessions();
},
onError: (error) => {
toast.error(`Failed to refresh sessions: ${error.message}`);
},
});
const handleRefreshSessions = () => {
refreshSessionsMutation.mutate();
};
const handleSearchChange = (search: string) => {
setSessionFilters((prev) => ({ ...prev, search, page: 1 }));
};
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">tRPC Demo</h2>
<Button
onClick={handleRefreshSessions}
disabled={refreshSessionsMutation.isPending}
variant="outline"
>
{refreshSessionsMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Refresh Sessions
</Button>
</div>
{/* Overview Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Total Sessions
</CardTitle>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</div>
) : (
<div className="text-2xl font-bold">
{overview?.totalSessions || 0}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Avg Messages</CardTitle>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</div>
) : (
<div className="text-2xl font-bold">
{Math.round(overview?.avgMessagesSent || 0)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Sentiment Distribution
</CardTitle>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</div>
) : (
<div className="space-y-1">
{overview?.sentimentDistribution.map((item) => (
<div
key={item.sentiment}
className="flex justify-between text-sm"
>
<span>{item.sentiment}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Top Questions */}
<Card>
<CardHeader>
<CardTitle>Top Questions</CardTitle>
</CardHeader>
<CardContent>
{questionsLoading ? (
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading questions...
</div>
) : (
<div className="space-y-2">
{topQuestions?.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm">{item.question}</span>
<Badge>{item.count}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Sessions List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Sessions
<div className="flex items-center space-x-2">
<Input
placeholder="Search sessions..."
value={sessionFilters.search}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-64"
/>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{sessionsError && (
<div className="text-red-600 mb-4">
Error loading sessions: {sessionsError.message}
</div>
)}
{sessionsLoading ? (
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading sessions...
</div>
) : (
<div className="space-y-4">
{sessions?.sessions.map((session) => (
<div key={session.id} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<span className="font-medium">Session {session.id}</span>
<Badge
variant={
session.sentiment === "POSITIVE"
? "default"
: session.sentiment === "NEGATIVE"
? "destructive"
: "secondary"
}
>
{session.sentiment}
</Badge>
</div>
<span className="text-sm text-muted-foreground">
{session.messagesSent} messages
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">
{session.summary}
</p>
{session.questions && session.questions.length > 0 && (
<div className="flex flex-wrap gap-1">
{session.questions.slice(0, 3).map((question, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{question.length > 50
? `${question.slice(0, 50)}...`
: question}
</Badge>
))}
</div>
)}
</div>
))}
{/* Pagination Info */}
{sessions && (
<div className="text-center text-sm text-muted-foreground">
Showing {sessions.sessions.length} of{" "}
{sessions.pagination.totalCount} sessions (Page{" "}
{sessions.pagination.page} of {sessions.pagination.totalPages}
)
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,42 @@
/**
* tRPC Provider Component
*
* Simplified provider for tRPC integration.
* The tRPC client is configured in trpc-client.ts and used directly in components.
*/
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
interface TRPCProviderProps {
children: React.ReactNode;
}
export function TRPCProvider({ children }: TRPCProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Disable automatic refetching for better UX
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes (was cacheTime)
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}