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:
2025-07-05 13:42:47 +02:00
committed by Kaj Kowalski
parent 19628233ea
commit a0ac60cf04
36 changed files with 10714 additions and 5292 deletions

View File

@ -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

View File

@ -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);

View File

@ -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()

View File

@ -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>

View File

@ -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;
}

View File

@ -15,4 +15,4 @@ interface ThemeProviderProps {
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
}