mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:12:11 +01:00
feat: implement comprehensive email system with rate limiting and extensive test suite
- Add robust email service with rate limiting and configuration management - Implement shared rate limiter utility for consistent API protection - Create comprehensive test suite for core processing pipeline - Add API tests for dashboard metrics and authentication routes - Fix date range picker infinite loop issue - Improve session lookup in refresh sessions API - Refactor session API routing with better code organization - Update processing pipeline status monitoring - Clean up leftover files and improve code formatting
This commit is contained in:
@ -25,11 +25,7 @@ export default function DateRangePicker({
|
||||
useEffect(() => {
|
||||
// Only notify parent component when dates change, not when the callback changes
|
||||
onDateRangeChange(startDate, endDate);
|
||||
}, [
|
||||
startDate,
|
||||
endDate, // Only notify parent component when dates change, not when the callback changes
|
||||
onDateRangeChange,
|
||||
]);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const handleStartDateChange = (newStartDate: string) => {
|
||||
// Ensure start date is not before min date
|
||||
|
||||
@ -82,61 +82,107 @@ export default function GeographicMap({
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Extract coordinates from a geometry feature
|
||||
*/
|
||||
function extractCoordinatesFromGeometry(
|
||||
geometry: any
|
||||
): [number, number] | undefined {
|
||||
if (geometry.type === "Point") {
|
||||
const [lon, lat] = geometry.coordinates;
|
||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
|
||||
if (
|
||||
geometry.type === "Polygon" &&
|
||||
geometry.coordinates &&
|
||||
geometry.coordinates[0] &&
|
||||
geometry.coordinates[0][0]
|
||||
) {
|
||||
// For Polygons, use the first coordinate of the first ring as a fallback representative point
|
||||
const [lon, lat] = geometry.coordinates[0][0];
|
||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
|
||||
if (
|
||||
geometry.type === "MultiPolygon" &&
|
||||
geometry.coordinates &&
|
||||
geometry.coordinates[0] &&
|
||||
geometry.coordinates[0][0] &&
|
||||
geometry.coordinates[0][0][0]
|
||||
) {
|
||||
// For MultiPolygons, use the first coordinate of the first ring of the first polygon
|
||||
const [lon, lat] = geometry.coordinates[0][0][0];
|
||||
return [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coordinates for a country code
|
||||
*/
|
||||
function getCountryCoordinates(
|
||||
code: string,
|
||||
countryCoordinates: Record<string, [number, number]>
|
||||
): [number, number] | undefined {
|
||||
// Try predefined coordinates first
|
||||
let coords = countryCoordinates[code] || DEFAULT_COORDINATES[code];
|
||||
|
||||
if (!coords) {
|
||||
// Try to get coordinates from country coder
|
||||
const feature = countryCoder.feature(code);
|
||||
if (feature?.geometry) {
|
||||
coords = extractCoordinatesFromGeometry(feature.geometry);
|
||||
}
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single country entry into CountryData
|
||||
*/
|
||||
function processCountryEntry(
|
||||
code: string,
|
||||
count: number,
|
||||
countryCoordinates: Record<string, [number, number]>
|
||||
): CountryData | null {
|
||||
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
||||
|
||||
if (coordinates) {
|
||||
return { code, count, coordinates };
|
||||
}
|
||||
|
||||
return null; // Skip if no coordinates found
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all countries data into CountryData array
|
||||
*/
|
||||
function processCountriesData(
|
||||
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`
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Process country data when client is ready and dependencies change
|
||||
useEffect(() => {
|
||||
if (!isClient || !countries) return;
|
||||
|
||||
try {
|
||||
// Generate CountryData array for the Map component
|
||||
const data: CountryData[] = Object.entries(countries || {})
|
||||
.map(([code, count]) => {
|
||||
let countryCoords: [number, number] | undefined =
|
||||
countryCoordinates[code] || DEFAULT_COORDINATES[code];
|
||||
|
||||
if (!countryCoords) {
|
||||
const feature = countryCoder.feature(code);
|
||||
if (feature?.geometry) {
|
||||
if (feature.geometry.type === "Point") {
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
} else if (
|
||||
feature.geometry.type === "Polygon" &&
|
||||
feature.geometry.coordinates &&
|
||||
feature.geometry.coordinates[0] &&
|
||||
feature.geometry.coordinates[0][0]
|
||||
) {
|
||||
// For Polygons, use the first coordinate of the first ring as a fallback representative point
|
||||
const [lon, lat] = feature.geometry.coordinates[0][0];
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
} else if (
|
||||
feature.geometry.type === "MultiPolygon" &&
|
||||
feature.geometry.coordinates &&
|
||||
feature.geometry.coordinates[0] &&
|
||||
feature.geometry.coordinates[0][0] &&
|
||||
feature.geometry.coordinates[0][0][0]
|
||||
) {
|
||||
// For MultiPolygons, use the first coordinate of the first ring of the first polygon
|
||||
const [lon, lat] = feature.geometry.coordinates[0][0][0];
|
||||
countryCoords = [lat, lon]; // Leaflet expects [lat, lon]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (countryCoords) {
|
||||
return {
|
||||
code,
|
||||
count,
|
||||
coordinates: countryCoords,
|
||||
};
|
||||
}
|
||||
return null; // Skip if no coordinates found
|
||||
})
|
||||
.filter((item): item is CountryData => item !== null);
|
||||
|
||||
console.log(
|
||||
`Found ${data.length} countries with coordinates out of ${Object.keys(countries).length} total countries`
|
||||
);
|
||||
|
||||
const data = processCountriesData(countries, countryCoordinates);
|
||||
setCountryData(data);
|
||||
} catch (error) {
|
||||
console.error("Error processing geographic data:", error);
|
||||
|
||||
@ -71,7 +71,8 @@ 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()
|
||||
|
||||
@ -64,7 +64,11 @@ export default function TopQuestionsChart({
|
||||
</div>
|
||||
|
||||
{/* Rank indicator */}
|
||||
<div className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center">
|
||||
<div
|
||||
className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center"
|
||||
role="img"
|
||||
aria-label={`Rank ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,83 @@ interface TranscriptViewerProps {
|
||||
transcriptUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a message bubble with proper styling
|
||||
*/
|
||||
function renderMessageBubble(
|
||||
speaker: string,
|
||||
messages: string[],
|
||||
key: string
|
||||
): React.ReactNode {
|
||||
return (
|
||||
<div key={key} className={`mb-3 ${speaker === "User" ? "text-right" : ""}`}>
|
||||
<div
|
||||
className={`inline-block px-4 py-2 rounded-lg ${
|
||||
speaker === "User"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{messages.map((msg, i) => (
|
||||
<ReactMarkdown
|
||||
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
p: "span",
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className="text-sky-600 hover:text-sky-800 underline"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</ReactMarkdown>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a line indicates a new speaker
|
||||
*/
|
||||
function isNewSpeakerLine(line: string): boolean {
|
||||
return line.startsWith("User:") || line.startsWith("Assistant:");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts speaker and message content from a speaker line
|
||||
*/
|
||||
function extractSpeakerInfo(line: string): {
|
||||
speaker: string;
|
||||
content: string;
|
||||
} {
|
||||
const speaker = line.startsWith("User:") ? "User" : "Assistant";
|
||||
const content = line.substring(line.indexOf(":") + 1).trim();
|
||||
return { speaker, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes accumulated messages for a speaker
|
||||
*/
|
||||
function processAccumulatedMessages(
|
||||
currentSpeaker: string | null,
|
||||
currentMessages: string[],
|
||||
elements: React.ReactNode[]
|
||||
): void {
|
||||
if (currentSpeaker && currentMessages.length > 0) {
|
||||
elements.push(
|
||||
renderMessageBubble(
|
||||
currentSpeaker,
|
||||
currentMessages,
|
||||
`message-${elements.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the transcript content into a more readable format with styling
|
||||
*/
|
||||
@ -17,114 +94,38 @@ function formatTranscript(content: string): React.ReactNode[] {
|
||||
return [<p key="empty">No transcript content available.</p>];
|
||||
}
|
||||
|
||||
// Split the transcript by lines
|
||||
const lines = content.split("\n");
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let currentSpeaker: string | null = null;
|
||||
let currentMessages: string[] = [];
|
||||
|
||||
// Process each line
|
||||
lines.forEach((line) => {
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) {
|
||||
// Empty line, ignore
|
||||
return;
|
||||
continue; // Skip empty lines
|
||||
}
|
||||
|
||||
// Check if this is a new speaker line
|
||||
if (line.startsWith("User:") || line.startsWith("Assistant:")) {
|
||||
// If we have accumulated messages for a previous speaker, add them
|
||||
if (currentSpeaker && currentMessages.length > 0) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`message-${elements.length}`}
|
||||
className={`mb-3 ${currentSpeaker === "User" ? "text-right" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`inline-block px-4 py-2 rounded-lg ${
|
||||
currentSpeaker === "User"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{currentMessages.map((msg, i) => (
|
||||
// Use ReactMarkdown to render each message part
|
||||
<ReactMarkdown
|
||||
key={`msg-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||
components={{
|
||||
p: "span",
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className="text-sky-600 hover:text-sky-800 underline"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</ReactMarkdown>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
currentMessages = [];
|
||||
}
|
||||
if (isNewSpeakerLine(line)) {
|
||||
// Process any accumulated messages from previous speaker
|
||||
processAccumulatedMessages(currentSpeaker, currentMessages, elements);
|
||||
currentMessages = [];
|
||||
|
||||
// Set the new current speaker
|
||||
currentSpeaker = trimmedLine.startsWith("User:") ? "User" : "Assistant";
|
||||
// Add the content after "User:" or "Assistant:"
|
||||
const messageContent = trimmedLine
|
||||
.substring(trimmedLine.indexOf(":") + 1)
|
||||
.trim();
|
||||
if (messageContent) {
|
||||
currentMessages.push(messageContent);
|
||||
// Set new speaker and add initial content
|
||||
const { speaker, content } = extractSpeakerInfo(trimmedLine);
|
||||
currentSpeaker = speaker;
|
||||
if (content) {
|
||||
currentMessages.push(content);
|
||||
}
|
||||
} else if (currentSpeaker) {
|
||||
// This is a continuation of the current speaker's message
|
||||
// Continuation of current speaker's message
|
||||
currentMessages.push(trimmedLine);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any remaining messages
|
||||
if (currentSpeaker && currentMessages.length > 0) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`message-${elements.length}`}
|
||||
className={`mb-3 ${currentSpeaker === "User" ? "text-right" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`inline-block px-4 py-2 rounded-lg ${
|
||||
currentSpeaker === "User"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{currentMessages.map((msg, i) => (
|
||||
// Use ReactMarkdown to render each message part
|
||||
<ReactMarkdown
|
||||
key={`msg-final-${msg.substring(0, 20).replace(/\s/g, "-")}-${i}`}
|
||||
rehypePlugins={[rehypeRaw]} // Add rehypeRaw to plugins
|
||||
components={{
|
||||
p: "span",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className="text-sky-600 hover:text-sky-800 underline"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</ReactMarkdown>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Process any remaining messages
|
||||
processAccumulatedMessages(currentSpeaker, currentMessages, elements);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
|
||||
@ -15,4 +15,4 @@ interface ThemeProviderProps {
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user