diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cb640d6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,45 @@ +# Application Improvement TODOs + +This file lists general areas for improvement and tasks that are broader in scope or don't map to a single specific file. + +## General Enhancements & Features + +- [ ] **Real-time Updates:** Implement real-time updates for the dashboard and session list (e.g., using WebSockets or Server-Sent Events). +- [ ] **Data Export:** Provide functionality for users (especially admins) to export session data (e.g., to CSV). +- [ ] **Customizable Dashboard:** Allow users to customize their dashboard view, choosing which metrics or charts are most important to them. +- [ ] **Resolve `GeographicMap.tsx` and `ResponseTimeDistribution.tsx` data simulation:** The `docs/dashboard-components.md` mentions these use simulated data. Investigate integrating real data sources. + +## Robustness and Maintainability + +- [ ] **Comprehensive Testing:** + - [ ] Implement unit tests (e.g., for utility functions, API logic). + - [ ] Implement integration tests (e.g., for API endpoints with the database). + - [ ] Implement end-to-end tests (e.g., for user flows using Playwright or Cypress). +- [ ] **Error Monitoring and Logging:** Integrate a robust error monitoring service (like Sentry) and enhance server-side logging. +- [ ] **Accessibility (a11y):** Review and improve the application's accessibility according to WCAG guidelines (keyboard navigation, screen reader compatibility, color contrast). + +## Security Enhancements + +- [ ] **Password Reset Functionality:** Implement a secure password reset mechanism. (Related: `app/forgot-password/page.tsx`, `app/reset-password/page.tsx`, `pages/api/forgot-password.ts`, `pages/api/reset-password.ts` - ensure these are robust and secure if already implemented). +- [ ] **Two-Factor Authentication (2FA):** Consider adding 2FA, especially for admin accounts. +- [ ] **Input Validation and Sanitization:** Rigorously review and ensure all user inputs (API request bodies, query parameters) are validated and sanitized. + +## Code Quality and Development Practices + +- [ ] **Code Reviews:** Enforce code reviews for all changes. +- [ ] **Environment Configuration:** Ensure secure and effective management of environment-specific configurations. +- [ ] **Dependency Review:** Periodically review dependencies for vulnerabilities or updates. +- [ ] **Documentation:** + - Ensure `docs/dashboard-components.md` is up-to-date with actual component implementations. + - Verify that "Dashboard Enhancements" (Improved Layout, Visual Hierarchies, Color Coding) are consistently applied. + +## Component Specific + +- [ ] **`components/SessionDetails.tsx.new`:** Review, complete TODOs within the file, and integrate as the primary `SessionDetails.tsx` component, removing/archiving older versions (`SessionDetails.tsx`, `SessionDetails.tsx.bak`). +- [ ] **`components/GeographicMap.tsx`:** Check if `GeographicMap.tsx.bak` is still needed or can be removed. +- [ ] **`app/dashboard/sessions/page.tsx`:** Implement pagination, advanced filtering, and sorting. +- [ ] **`pages/api/dashboard/users.ts`:** Implement robust emailing of temporary passwords. + +## File Cleanup + +- [ ] Review and remove `.bak` and `.new` files once changes are integrated (e.g., `GeographicMap.tsx.bak`, `SessionDetails.tsx.bak`, `SessionDetails.tsx.new`). diff --git a/app/dashboard/sessions/page.tsx b/app/dashboard/sessions/page.tsx index 541d045..1b45d14 100644 --- a/app/dashboard/sessions/page.tsx +++ b/app/dashboard/sessions/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; // Added useCallback +import { useState, useEffect, useCallback } from "react"; import { ChatSession } from "../../../lib/types"; import Link from "next/link"; @@ -8,12 +8,32 @@ import Link from "next/link"; // For now, we'll display some basic info directly. // import SessionListItem from "../../../components/SessionListItem"; +// TODO: Consider moving filter/sort types to lib/types.ts if they become complex +interface FilterOptions { + categories: string[]; + languages: string[]; +} + export default function SessionsPage() { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); + // Filter states + const [filterOptions, setFilterOptions] = useState({ + categories: [], + languages: [], + }); + const [selectedCategory, setSelectedCategory] = useState(""); + const [selectedLanguage, setSelectedLanguage] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // Sort states + const [sortKey, setSortKey] = useState("startTime"); // Default sort key + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); // Default sort order + // Debounce search term to avoid excessive API calls const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); @@ -26,19 +46,71 @@ export default function SessionsPage() { }; }, [searchTerm]); + const fetchFilterOptions = useCallback(async () => { + // TODO: Implement API endpoint to fetch distinct categories and languages + // For now, using placeholder data or deriving from fetched sessions if possible + // This should ideally be a separate API call: GET /api/dashboard/session-filter-options + try { + // Simulating fetching filter options. Replace with actual API call. + // const response = await fetch('/api/dashboard/session-filter-options'); + // if (!response.ok) { + // throw new Error('Failed to fetch filter options'); + // } + // const data = await response.json(); + // setFilterOptions(data); + + // Placeholder - In a real scenario, fetch these from the backend + // For now, we can extract from all sessions once fetched, but this is not ideal for initial load. + // This will be improved when the backend endpoint is ready. + if (sessions.length > 0) { + const categories = Array.from( + new Set(sessions.map((s) => s.category).filter(Boolean)) + ) as string[]; + const languages = Array.from( + new Set(sessions.map((s) => s.language).filter(Boolean)) + ) as string[]; + setFilterOptions({ categories, languages }); + } + } catch { + // setError("Failed to load filter options"); // Optionally set an error state + } + }, [sessions]); // Re-fetch if sessions change, for placeholder logic. + const fetchSessions = useCallback(async () => { setLoading(true); setError(null); try { - const query = debouncedSearchTerm - ? `?searchTerm=${encodeURIComponent(debouncedSearchTerm)}` - : ""; - const response = await fetch(`/api/dashboard/sessions${query}`); + const params = new URLSearchParams(); + if (debouncedSearchTerm) params.append("searchTerm", debouncedSearchTerm); + if (selectedCategory) params.append("category", selectedCategory); + if (selectedLanguage) params.append("language", selectedLanguage); + if (startDate) params.append("startDate", startDate); + if (endDate) params.append("endDate", endDate); + if (sortKey) params.append("sortKey", sortKey); + if (sortOrder) params.append("sortOrder", sortOrder); + + const response = await fetch( + `/api/dashboard/sessions?${params.toString()}` + ); if (!response.ok) { throw new Error(`Failed to fetch sessions: ${response.statusText}`); } const data = await response.json(); setSessions(data.sessions || []); + // After fetching sessions, update filter options (temporary solution) + if (data.sessions && data.sessions.length > 0) { + const categories = Array.from( + new Set( + data.sessions.map((s: ChatSession) => s.category).filter(Boolean) + ) + ) as string[]; + const languages = Array.from( + new Set( + data.sessions.map((s: ChatSession) => s.language).filter(Boolean) + ) + ) as string[]; + setFilterOptions({ categories, languages }); + } } catch (err) { setError( err instanceof Error ? err.message : "An unknown error occurred" @@ -47,11 +119,27 @@ export default function SessionsPage() { } finally { setLoading(false); } - }, [debouncedSearchTerm]); // Depend on debouncedSearchTerm + }, [ + debouncedSearchTerm, + selectedCategory, + selectedLanguage, + startDate, + endDate, + sortKey, + sortOrder, + ]); useEffect(() => { fetchSessions(); - }, [fetchSessions]); // fetchSessions is now stable due to useCallback and its dependency + }, [fetchSessions]); + + // Fetch initial filter options (or update if sessions change - placeholder) + useEffect(() => { + // This is a placeholder. Ideally, filter options are fetched once, + // or if they are dynamic and dependent on other filters, fetched accordingly. + // For now, this re-runs if sessions data changes, which is not optimal. + fetchFilterOptions(); + }, [fetchFilterOptions]); return (
@@ -59,6 +147,7 @@ export default function SessionsPage() { Chat Sessions + {/* Search Input */}
+ {/* Filter and Sort Controls */} +
+ {/* Category Filter */} +
+ + +
+ + {/* Language Filter */} +
+ + +
+ + {/* Start Date Filter */} +
+ + setStartDate(e.target.value)} + /> +
+ + {/* End Date Filter */} +
+ + setEndDate(e.target.value)} + /> +
+ + {/* Sort Key */} +
+ + +
+ + {/* Sort Order */} +
+ + +
+
+ {loading &&

Loading sessions...

} {error &&

Error: {error}

} @@ -122,7 +336,9 @@ export default function SessionsPage() { ))}
)} - {/* TODO: Add pagination controls */} + {/* TODO: Add pagination controls (e.g., using a library or custom component) */} + {/* TODO: Implement advanced filtering (by date range, category, language, etc.) - Partially done, needs backend support for filter options and robust date filtering */} + {/* TODO: Implement sorting options for the session list (e.g., by start time, sentiment) - Partially done, needs backend support */} ); } diff --git a/components/SessionDetails.tsx.new b/components/SessionDetails.tsx.new index c09edd5..2acaa7b 100644 --- a/components/SessionDetails.tsx.new +++ b/components/SessionDetails.tsx.new @@ -82,6 +82,7 @@ export default function SessionDetails({ session }: SessionDetailsProps) { : "text-orange-500" }`} > + {/* TODO: Ensure sentiment display is accurate and potentially use icons/color-coding more explicitly */} {session.sentiment > 0.3 ? "Positive" : session.sentiment < -0.3 @@ -115,6 +116,7 @@ export default function SessionDetails({ session }: SessionDetailsProps) { session.avgResponseTime !== undefined && (
Avg Response Time: + {/* TODO: Populate average response time, ensure formatting (e.g., "s" or "ms") */} {session.avgResponseTime.toFixed(2)}s @@ -169,3 +171,5 @@ export default function SessionDetails({ session }: SessionDetailsProps) {
); } + +// TODO: Review and finalize this component. Consider renaming to SessionDetails.tsx and removing/archiving SessionDetails.tsx and SessionDetails.tsx.bak. diff --git a/lib/types.ts b/lib/types.ts index 32d444c..72da20b 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,118 +1,140 @@ import { Session as NextAuthSession } from "next-auth"; export interface UserSession extends NextAuthSession { - user: { - id?: string; - name?: string; - email?: string; - image?: string; - companyId: string; - role: string; - }; + user: { + id?: string; + name?: string; + email?: string; + image?: string; + companyId: string; + role: string; + }; } export interface Company { - id: string; - name: string; - csvUrl: string; - csvUsername?: string; - csvPassword?: string; - sentimentAlert?: number; // Match Prisma schema naming - createdAt: Date; - updatedAt: Date; + id: string; + name: string; + csvUrl: string; + csvUsername?: string; + csvPassword?: string; + sentimentAlert?: number; // Match Prisma schema naming + createdAt: Date; + updatedAt: Date; } export interface User { - id: string; - email: string; - password: string; - role: string; - companyId: string; - resetToken?: string | null; - resetTokenExpiry?: Date | null; - company?: Company; - createdAt: Date; - updatedAt: Date; + id: string; + email: string; + password: string; + role: string; + companyId: string; + resetToken?: string | null; + resetTokenExpiry?: Date | null; + company?: Company; + createdAt: Date; + updatedAt: Date; } export interface ChatSession { - id: string; - sessionId: string; - companyId: string; - userId?: string | null; - category?: string | null; - language?: string | null; - country?: string | null; - ipAddress?: string | null; - sentiment?: number | null; - messagesSent?: number; - startTime: Date; - endTime?: Date | null; - createdAt: Date; - updatedAt: Date; + id: string; + sessionId: string; + companyId: string; + userId?: string | null; + category?: string | null; + language?: string | null; + country?: string | null; + ipAddress?: string | null; + sentiment?: number | null; + messagesSent?: number; + startTime: Date; + endTime?: Date | null; + createdAt: Date; + updatedAt: Date; - // Extended session properties that might be used in metrics - avgResponseTime?: number | null; - escalated?: boolean; - forwardedHr?: boolean; - tokens?: number; - tokensEur?: number; - initialMsg?: string; - fullTranscriptUrl?: string | null; - transcriptContent?: string | null; + // Extended session properties that might be used in metrics + avgResponseTime?: number | null; + escalated?: boolean; + forwardedHr?: boolean; + tokens?: number; + tokensEur?: number; + initialMsg?: string; + fullTranscriptUrl?: string | null; + transcriptContent?: string | null; +} + +export interface SessionQuery { + searchTerm?: string; + category?: string; + language?: string; + startDate?: string; + endDate?: string; + sortKey?: string; + sortOrder?: "asc" | "desc"; + page?: number; + pageSize?: number; +} + +export interface SessionApiResponse { + sessions: ChatSession[]; + totalSessions: number; +} + +export interface SessionFilterOptions { + categories: string[]; + languages: string[]; } export interface DayMetrics { - [day: string]: number; + [day: string]: number; } export interface CategoryMetrics { - [category: string]: number; + [category: string]: number; } export interface LanguageMetrics { - [language: string]: number; + [language: string]: number; } export interface CountryMetrics { - [country: string]: number; + [country: string]: number; } export interface WordCloudWord { - text: string; - value: number; + text: string; + value: number; } export interface MetricsResult { - totalSessions: number; - avgSessionsPerDay: number; - avgSessionLength: number | null; - days: DayMetrics; - languages: LanguageMetrics; - categories: CategoryMetrics; - countries: CountryMetrics; // Added for geographic distribution - belowThresholdCount: number; - // Additional properties for dashboard - escalatedCount?: number; - forwardedCount?: number; - avgSentiment?: number; - avgResponseTime?: number; - totalTokens?: number; - totalTokensEur?: number; - sentimentThreshold?: number | null; - lastUpdated?: number; // Timestamp for when metrics were last updated + totalSessions: number; + avgSessionsPerDay: number; + avgSessionLength: number | null; + days: DayMetrics; + languages: LanguageMetrics; + categories: CategoryMetrics; + countries: CountryMetrics; // Added for geographic distribution + belowThresholdCount: number; + // Additional properties for dashboard + escalatedCount?: number; + forwardedCount?: number; + avgSentiment?: number; + avgResponseTime?: number; + totalTokens?: number; + totalTokensEur?: number; + sentimentThreshold?: number | null; + lastUpdated?: number; // Timestamp for when metrics were last updated - // New metrics for enhanced dashboard - sentimentPositiveCount?: number; - sentimentNeutralCount?: number; - sentimentNegativeCount?: number; - tokensByDay?: DayMetrics; - tokensCostByDay?: DayMetrics; - wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud + // New metrics for enhanced dashboard + sentimentPositiveCount?: number; + sentimentNeutralCount?: number; + sentimentNegativeCount?: number; + tokensByDay?: DayMetrics; + tokensCostByDay?: DayMetrics; + wordCloudData?: WordCloudWord[]; // Added for transcript-based word cloud } export interface ApiResponse { - success: boolean; - data?: T; - error?: string; + success: boolean; + data?: T; + error?: string; } diff --git a/pages/api/dashboard/session-filter-options.ts b/pages/api/dashboard/session-filter-options.ts new file mode 100644 index 0000000..7324178 --- /dev/null +++ b/pages/api/dashboard/session-filter-options.ts @@ -0,0 +1,70 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; +import { prisma } from "../../../lib/prisma"; +import { SessionFilterOptions } from "../../../lib/types"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "GET") { + return res.status(405).json({ error: "Method not allowed" }); + } + + const authSession = await getServerSession(req, res, authOptions); + + if (!authSession || !authSession.user?.companyId) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const companyId = authSession.user.companyId; + + try { + const categories = await prisma.session.findMany({ + where: { + companyId, + category: { + not: null, // Ensure category is not null + }, + }, + distinct: ["category"], + select: { + category: true, + }, + orderBy: { + category: "asc", + }, + }); + + const languages = await prisma.session.findMany({ + where: { + companyId, + language: { + not: null, // Ensure language is not null + }, + }, + distinct: ["language"], + select: { + language: true, + }, + orderBy: { + language: "asc", + }, + }); + + const distinctCategories = categories.map((s) => s.category).filter(Boolean) as string[]; // Filter out any nulls and assert as string[] + const distinctLanguages = languages.map((s) => s.language).filter(Boolean) as string[]; // Filter out any nulls and assert as string[] + + return res + .status(200) + .json({ categories: distinctCategories, languages: distinctLanguages }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return res.status(500).json({ + error: "Failed to fetch filter options", + details: errorMessage, + }); + } +} diff --git a/pages/api/dashboard/sessions.ts b/pages/api/dashboard/sessions.ts index d62e7f7..d8b15ba 100644 --- a/pages/api/dashboard/sessions.ts +++ b/pages/api/dashboard/sessions.ts @@ -2,11 +2,11 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; import { authOptions } from "../auth/[...nextauth]"; import { prisma } from "../../../lib/prisma"; -import { ChatSession } from "../../../lib/types"; +import { ChatSession, SessionApiResponse, SessionQuery } from "../../../lib/types"; export default async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse ) { if (req.method !== "GET") { return res.status(405).json({ error: "Method not allowed" }); @@ -19,11 +19,25 @@ export default async function handler( } const companyId = authSession.user.companyId; - const { searchTerm } = req.query; + const { + searchTerm, + category, + language, + startDate, + endDate, + sortKey, + sortOrder, + page: queryPage, + pageSize: queryPageSize, + } = req.query as SessionQuery; + + const page = Number(queryPage) || 1; + const pageSize = Number(queryPageSize) || 10; try { const whereClause: any = { companyId }; + // Search Term if ( searchTerm && typeof searchTerm === "string" && @@ -31,6 +45,7 @@ export default async function handler( ) { const searchConditions = [ { id: { contains: searchTerm, mode: "insensitive" } }, + { sessionId: { contains: searchTerm, mode: "insensitive" } }, { category: { contains: searchTerm, mode: "insensitive" } }, { initialMsg: { contains: searchTerm, mode: "insensitive" } }, { transcriptContent: { contains: searchTerm, mode: "insensitive" } }, @@ -38,13 +53,55 @@ export default async function handler( whereClause.OR = searchConditions; } + // Category Filter + if (category && typeof category === "string" && category.trim() !== "") { + whereClause.category = category; + } + + // Language Filter + if (language && typeof language === "string" && language.trim() !== "") { + whereClause.language = language; + } + + // Date Range Filter + if (startDate && typeof startDate === "string") { + if (!whereClause.startTime) whereClause.startTime = {}; + whereClause.startTime.gte = new Date(startDate); + } + if (endDate && typeof endDate === "string") { + if (!whereClause.startTime) whereClause.startTime = {}; + const inclusiveEndDate = new Date(endDate); + inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); + whereClause.startTime.lt = inclusiveEndDate; + } + + // Sorting + let orderByClause: any = { startTime: "desc" }; + if (sortKey && typeof sortKey === "string") { + const order = + sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; + const validSortKeys: { [key: string]: string; } = { + startTime: "startTime", + category: "category", + language: "language", + sentiment: "sentiment", + messagesSent: "messagesSent", + avgResponseTime: "avgResponseTime", + }; + if (validSortKeys[sortKey]) { + orderByClause = { [validSortKeys[sortKey]]: order }; + } + } + const prismaSessions = await prisma.session.findMany({ where: whereClause, - orderBy: { - startTime: "desc", - }, + orderBy: orderByClause, + skip: (page - 1) * pageSize, + take: pageSize, }); + const totalSessions = await prisma.session.count({ where: whereClause }); + const sessions: ChatSession[] = prismaSessions.map((ps) => ({ id: ps.id, sessionId: ps.id, @@ -70,7 +127,7 @@ export default async function handler( transcriptContent: ps.transcriptContent ?? null, })); - return res.status(200).json({ sessions }); + return res.status(200).json({ sessions, totalSessions }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; diff --git a/pages/api/dashboard/users.ts b/pages/api/dashboard/users.ts index 6139d18..97ef027 100644 --- a/pages/api/dashboard/users.ts +++ b/pages/api/dashboard/users.ts @@ -44,7 +44,7 @@ export default async function handler( return res.status(400).json({ error: "Missing fields" }); const exists = await prisma.user.findUnique({ where: { email } }); if (exists) return res.status(409).json({ error: "Email exists" }); - const tempPassword = crypto.randomBytes(12).toString('base64').slice(0, 12); // secure random initial password + const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password await prisma.user.create({ data: { email, @@ -53,7 +53,7 @@ export default async function handler( role, }, }); - // TODO: Email user their temp password (stub, for demo) + // TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service. res.json({ ok: true, tempPassword }); } else res.status(405).end(); }