mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 14:12:10 +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:
@ -6,29 +6,7 @@ import { prisma } from "../../../../lib/prisma";
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
let { companyId } = body;
|
||||
|
||||
if (!companyId) {
|
||||
// Try to get user from prisma based on session cookie
|
||||
try {
|
||||
const session = await prisma.session.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
where: {
|
||||
/* Add session check criteria here */
|
||||
},
|
||||
});
|
||||
|
||||
if (session) {
|
||||
companyId = session.companyId;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error for server-side debugging
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// Use a server-side logging approach instead of console
|
||||
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||
}
|
||||
}
|
||||
const { companyId } = body;
|
||||
|
||||
if (!companyId) {
|
||||
return NextResponse.json(
|
||||
|
||||
@ -1,10 +1,141 @@
|
||||
import { SessionCategory, type Prisma } from "@prisma/client";
|
||||
import type { Prisma, SessionCategory } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import type { ChatSession } from "../../../../lib/types";
|
||||
|
||||
/**
|
||||
* Build where clause for session filtering
|
||||
*/
|
||||
function buildWhereClause(
|
||||
companyId: string,
|
||||
searchParams: URLSearchParams
|
||||
): Prisma.SessionWhereInput {
|
||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||
|
||||
const searchTerm = searchParams.get("searchTerm");
|
||||
const category = searchParams.get("category");
|
||||
const language = searchParams.get("language");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
// Search Term
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
const searchConditions = [
|
||||
{ id: { contains: searchTerm } },
|
||||
{ initialMsg: { contains: searchTerm } },
|
||||
{ summary: { contains: searchTerm } },
|
||||
];
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if (category && category.trim() !== "") {
|
||||
whereClause.category = category as SessionCategory;
|
||||
}
|
||||
|
||||
// Language Filter
|
||||
if (language && language.trim() !== "") {
|
||||
whereClause.language = language;
|
||||
}
|
||||
|
||||
// Date Range Filter
|
||||
if (startDate) {
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
gte: new Date(startDate),
|
||||
};
|
||||
}
|
||||
if (endDate) {
|
||||
const inclusiveEndDate = new Date(endDate);
|
||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
lt: inclusiveEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
return whereClause;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build order by clause for session sorting
|
||||
*/
|
||||
function buildOrderByClause(
|
||||
searchParams: URLSearchParams
|
||||
):
|
||||
| Prisma.SessionOrderByWithRelationInput
|
||||
| Prisma.SessionOrderByWithRelationInput[] {
|
||||
const sortKey = searchParams.get("sortKey");
|
||||
const sortOrder = searchParams.get("sortOrder");
|
||||
|
||||
const validSortKeys: { [key: string]: string } = {
|
||||
startTime: "startTime",
|
||||
category: "category",
|
||||
language: "language",
|
||||
sentiment: "sentiment",
|
||||
messagesSent: "messagesSent",
|
||||
avgResponseTime: "avgResponseTime",
|
||||
};
|
||||
|
||||
const primarySortField =
|
||||
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime";
|
||||
const primarySortOrder =
|
||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
|
||||
|
||||
if (primarySortField === "startTime") {
|
||||
return { [primarySortField]: primarySortOrder };
|
||||
}
|
||||
|
||||
return [{ [primarySortField]: primarySortOrder }, { startTime: "desc" }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Prisma session to ChatSession format
|
||||
*/
|
||||
function convertPrismaSessionToChatSession(ps: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
createdAt: Date;
|
||||
category: string | null;
|
||||
language: string | null;
|
||||
country: string | null;
|
||||
ipAddress: string | null;
|
||||
sentiment: string | null;
|
||||
messagesSent: number | null;
|
||||
avgResponseTime: number | null;
|
||||
escalated: boolean | null;
|
||||
forwardedHr: boolean | null;
|
||||
initialMsg: string | null;
|
||||
fullTranscriptUrl: string | null;
|
||||
}): ChatSession {
|
||||
return {
|
||||
id: ps.id,
|
||||
sessionId: ps.id,
|
||||
companyId: ps.companyId,
|
||||
startTime: new Date(ps.startTime),
|
||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||
createdAt: new Date(ps.createdAt),
|
||||
updatedAt: new Date(ps.createdAt),
|
||||
userId: null,
|
||||
category: ps.category ?? null,
|
||||
language: ps.language ?? null,
|
||||
country: ps.country ?? null,
|
||||
ipAddress: ps.ipAddress ?? null,
|
||||
sentiment: ps.sentiment ?? null,
|
||||
messagesSent: ps.messagesSent ?? undefined,
|
||||
avgResponseTime: ps.avgResponseTime ?? null,
|
||||
escalated: ps.escalated ?? undefined,
|
||||
forwardedHr: ps.forwardedHr ?? undefined,
|
||||
initialMsg: ps.initialMsg ?? undefined,
|
||||
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
||||
transcriptContent: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authSession = await getServerSession(authOptions);
|
||||
|
||||
@ -15,89 +146,14 @@ export async function GET(request: NextRequest) {
|
||||
const companyId = authSession.user.companyId;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const searchTerm = searchParams.get("searchTerm");
|
||||
const category = searchParams.get("category");
|
||||
const language = searchParams.get("language");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
const sortKey = searchParams.get("sortKey");
|
||||
const sortOrder = searchParams.get("sortOrder");
|
||||
const queryPage = searchParams.get("page");
|
||||
const queryPageSize = searchParams.get("pageSize");
|
||||
|
||||
const page = Number(queryPage) || 1;
|
||||
const pageSize = Number(queryPageSize) || 10;
|
||||
|
||||
try {
|
||||
const whereClause: Prisma.SessionWhereInput = { companyId };
|
||||
|
||||
// Search Term
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
const searchConditions = [
|
||||
{ id: { contains: searchTerm } },
|
||||
{ initialMsg: { contains: searchTerm } },
|
||||
{ summary: { contains: searchTerm } },
|
||||
];
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if (category && category.trim() !== "") {
|
||||
// Cast to SessionCategory enum if it's a valid value
|
||||
whereClause.category = category as SessionCategory;
|
||||
}
|
||||
|
||||
// Language Filter
|
||||
if (language && language.trim() !== "") {
|
||||
whereClause.language = language;
|
||||
}
|
||||
|
||||
// Date Range Filter
|
||||
if (startDate) {
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
gte: new Date(startDate),
|
||||
};
|
||||
}
|
||||
if (endDate) {
|
||||
const inclusiveEndDate = new Date(endDate);
|
||||
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
|
||||
whereClause.startTime = {
|
||||
...((whereClause.startTime as object) || {}),
|
||||
lt: inclusiveEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
// Sorting
|
||||
const validSortKeys: { [key: string]: string } = {
|
||||
startTime: "startTime",
|
||||
category: "category",
|
||||
language: "language",
|
||||
sentiment: "sentiment",
|
||||
messagesSent: "messagesSent",
|
||||
avgResponseTime: "avgResponseTime",
|
||||
};
|
||||
|
||||
let orderByCondition:
|
||||
| Prisma.SessionOrderByWithRelationInput
|
||||
| Prisma.SessionOrderByWithRelationInput[];
|
||||
|
||||
const primarySortField =
|
||||
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime"; // Default to startTime field if sortKey is invalid/missing
|
||||
|
||||
const primarySortOrder =
|
||||
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
|
||||
|
||||
if (primarySortField === "startTime") {
|
||||
// If sorting by startTime, it's the only sort criteria
|
||||
orderByCondition = { [primarySortField]: primarySortOrder };
|
||||
} else {
|
||||
// If sorting by another field, use startTime: "desc" as secondary sort
|
||||
orderByCondition = [
|
||||
{ [primarySortField]: primarySortOrder },
|
||||
{ startTime: "desc" },
|
||||
];
|
||||
}
|
||||
const whereClause = buildWhereClause(companyId, searchParams);
|
||||
const orderByCondition = buildOrderByClause(searchParams);
|
||||
|
||||
const prismaSessions = await prisma.session.findMany({
|
||||
where: whereClause,
|
||||
@ -108,28 +164,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const totalSessions = await prisma.session.count({ where: whereClause });
|
||||
|
||||
const sessions: ChatSession[] = prismaSessions.map((ps) => ({
|
||||
id: ps.id,
|
||||
sessionId: ps.id,
|
||||
companyId: ps.companyId,
|
||||
startTime: new Date(ps.startTime),
|
||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||
createdAt: new Date(ps.createdAt),
|
||||
updatedAt: new Date(ps.createdAt),
|
||||
userId: null,
|
||||
category: ps.category ?? null,
|
||||
language: ps.language ?? null,
|
||||
country: ps.country ?? null,
|
||||
ipAddress: ps.ipAddress ?? null,
|
||||
sentiment: ps.sentiment ?? null,
|
||||
messagesSent: ps.messagesSent ?? undefined,
|
||||
avgResponseTime: ps.avgResponseTime ?? null,
|
||||
escalated: ps.escalated ?? undefined,
|
||||
forwardedHr: ps.forwardedHr ?? undefined,
|
||||
initialMsg: ps.initialMsg ?? undefined,
|
||||
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
|
||||
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed
|
||||
}));
|
||||
const sessions: ChatSession[] = prismaSessions.map(
|
||||
convertPrismaSessionToChatSession
|
||||
);
|
||||
|
||||
return NextResponse.json({ sessions, totalSessions });
|
||||
} catch (error) {
|
||||
|
||||
@ -77,6 +77,17 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
|
||||
return NextResponse.json({ ok: true, tempPassword });
|
||||
const { sendPasswordResetEmail } = await import("../../../../lib/sendEmail");
|
||||
const emailResult = await sendPasswordResetEmail(email, tempPassword);
|
||||
|
||||
if (!emailResult.success) {
|
||||
console.warn("Failed to send password email:", emailResult.error);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tempPassword,
|
||||
emailSent: emailResult.success,
|
||||
emailError: emailResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,57 +1,25 @@
|
||||
import crypto from "node:crypto";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
||||
import { sendEmail } from "../../../lib/sendEmail";
|
||||
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
// In-memory rate limiting with automatic cleanup
|
||||
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
|
||||
const CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
const MAX_ENTRIES = 10000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
resetAttempts.forEach((attempts, ip) => {
|
||||
if (now > attempts.resetTime) {
|
||||
resetAttempts.delete(ip);
|
||||
}
|
||||
});
|
||||
}, CLEANUP_INTERVAL);
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
// Prevent unbounded growth
|
||||
if (resetAttempts.size > MAX_ENTRIES) {
|
||||
const entries = Array.from(resetAttempts.entries());
|
||||
entries.sort((a, b) => a[1].resetTime - b[1].resetTime);
|
||||
entries.slice(0, Math.floor(MAX_ENTRIES / 2)).forEach(([ip]) => {
|
||||
resetAttempts.delete(ip);
|
||||
});
|
||||
}
|
||||
const attempts = resetAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempts.count >= 5) {
|
||||
// Max 5 reset requests per 15 minutes per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
// Rate limiting for password reset endpoint
|
||||
const passwordResetLimiter = new InMemoryRateLimiter({
|
||||
maxAttempts: 5,
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
maxEntries: 10000,
|
||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
// Rate limiting check using shared utility
|
||||
const ip = extractClientIP(request);
|
||||
const rateLimitResult = passwordResetLimiter.checkRateLimit(ip);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@ -1,63 +1,24 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
|
||||
import { registerSchema, validateInput } from "../../../lib/validation";
|
||||
|
||||
// In-memory rate limiting with automatic cleanup
|
||||
const registrationAttempts = new Map<
|
||||
string,
|
||||
{ count: number; resetTime: number }
|
||||
>();
|
||||
|
||||
// Clean up expired entries every 5 minutes
|
||||
const CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
const MAX_ENTRIES = 10000; // Prevent unbounded growth
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
registrationAttempts.forEach((attempts, ip) => {
|
||||
if (now > attempts.resetTime) {
|
||||
registrationAttempts.delete(ip);
|
||||
}
|
||||
});
|
||||
}, CLEANUP_INTERVAL);
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
// Prevent unbounded growth
|
||||
if (registrationAttempts.size > MAX_ENTRIES) {
|
||||
// Remove oldest entries
|
||||
const entries = Array.from(registrationAttempts.entries());
|
||||
entries.sort((a, b) => a[1].resetTime - b[1].resetTime);
|
||||
entries.slice(0, Math.floor(MAX_ENTRIES / 2)).forEach(([ip]) => {
|
||||
registrationAttempts.delete(ip);
|
||||
});
|
||||
}
|
||||
const attempts = registrationAttempts.get(ip);
|
||||
|
||||
if (!attempts || now > attempts.resetTime) {
|
||||
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempts.count >= 3) {
|
||||
// Max 3 registrations per hour per IP
|
||||
return false;
|
||||
}
|
||||
|
||||
attempts.count++;
|
||||
return true;
|
||||
}
|
||||
// Rate limiting for registration endpoint
|
||||
const registrationLimiter = new InMemoryRateLimiter({
|
||||
maxAttempts: 3,
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxEntries: 10000,
|
||||
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting check - improved IP extraction
|
||||
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||
const ip = forwardedFor
|
||||
? forwardedFor.split(",")[0].trim() // Get first IP if multiple
|
||||
: request.headers.get("x-real-ip") ||
|
||||
"unknown";
|
||||
if (!checkRateLimit(ip)) {
|
||||
// Rate limiting check using shared utility
|
||||
const ip = extractClientIP(request);
|
||||
const rateLimitResult = registrationLimiter.checkRateLimit(ip);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
Reference in New Issue
Block a user