mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:32:10 +01:00
feat: add repository pattern, service layer architecture, and scheduler management
- Implement repository pattern for data access layer - Add comprehensive service layer for business logic - Create scheduler management system with health monitoring - Add bounded buffer utility for memory management - Enhance security audit logging with retention policies
This commit is contained in:
@ -1,3 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
@ -5,7 +6,9 @@ import { prisma } from "../../../../lib/prisma";
|
||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
||||
import {
|
||||
AuditOutcome,
|
||||
type AuditSeverity,
|
||||
createAuditMetadata,
|
||||
type SecurityEventType,
|
||||
securityAuditLogger,
|
||||
} from "../../../../lib/securityAuditLogger";
|
||||
|
||||
@ -89,26 +92,16 @@ function parseAuditLogFilters(url: URL) {
|
||||
function buildAuditLogWhereClause(
|
||||
companyId: string,
|
||||
filters: ReturnType<typeof parseAuditLogFilters>
|
||||
) {
|
||||
): Prisma.SecurityAuditLogWhereInput {
|
||||
const { eventType, outcome, severity, userId, startDate, endDate } = filters;
|
||||
|
||||
const where: {
|
||||
companyId: string;
|
||||
eventType?: string;
|
||||
outcome?: string;
|
||||
severity?: string;
|
||||
userId?: string;
|
||||
timestamp?: {
|
||||
gte?: Date;
|
||||
lte?: Date;
|
||||
};
|
||||
} = {
|
||||
const where: Prisma.SecurityAuditLogWhereInput = {
|
||||
companyId, // Only show logs for user's company
|
||||
};
|
||||
|
||||
if (eventType) where.eventType = eventType;
|
||||
if (outcome) where.outcome = outcome;
|
||||
if (severity) where.severity = severity;
|
||||
if (eventType) where.eventType = eventType as SecurityEventType;
|
||||
if (outcome) where.outcome = outcome as AuditOutcome;
|
||||
if (severity) where.severity = severity as AuditSeverity;
|
||||
if (userId) where.userId = userId;
|
||||
|
||||
if (startDate || endDate) {
|
||||
|
||||
61
app/api/admin/schedulers/health/route.ts
Normal file
61
app/api/admin/schedulers/health/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSchedulerIntegration } from "@/lib/services/schedulers/ServerSchedulerIntegration";
|
||||
|
||||
/**
|
||||
* Health check endpoint for schedulers
|
||||
* Used by load balancers and orchestrators for health monitoring
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const integration = getSchedulerIntegration();
|
||||
const health = integration.getHealthStatus();
|
||||
|
||||
// Return appropriate HTTP status based on health
|
||||
const status = health.healthy ? 200 : 503;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
healthy: health.healthy,
|
||||
status: health.healthy ? "healthy" : "unhealthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
schedulers: {
|
||||
total: health.totalSchedulers,
|
||||
running: health.runningSchedulers,
|
||||
errors: health.errorSchedulers,
|
||||
},
|
||||
details: health.schedulerStatuses,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Scheduler Health API] Error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
healthy: false,
|
||||
status: "error",
|
||||
timestamp: new Date().toISOString(),
|
||||
error: "Failed to get scheduler health status",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness check endpoint
|
||||
* Used by Kubernetes and other orchestrators
|
||||
*/
|
||||
export async function HEAD() {
|
||||
try {
|
||||
const integration = getSchedulerIntegration();
|
||||
const health = integration.getHealthStatus();
|
||||
|
||||
// Return 200 if healthy, 503 if not
|
||||
const status = health.healthy ? 200 : 503;
|
||||
|
||||
return new NextResponse(null, { status });
|
||||
} catch (_error) {
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
131
app/api/admin/schedulers/route.ts
Normal file
131
app/api/admin/schedulers/route.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getSchedulerIntegration } from "@/lib/services/schedulers/ServerSchedulerIntegration";
|
||||
|
||||
/**
|
||||
* Get all schedulers with their status and metrics
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const integration = getSchedulerIntegration();
|
||||
const schedulers = integration.getSchedulersList();
|
||||
const health = integration.getHealthStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
health,
|
||||
schedulers,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Scheduler Management API] GET Error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Failed to get scheduler information",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control scheduler operations (start/stop/trigger)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, schedulerId } = body;
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Action is required",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const integration = getSchedulerIntegration();
|
||||
|
||||
switch (action) {
|
||||
case "start":
|
||||
if (!schedulerId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "schedulerId is required for start action",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await integration.startScheduler(schedulerId);
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
if (!schedulerId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "schedulerId is required for stop action",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await integration.stopScheduler(schedulerId);
|
||||
break;
|
||||
|
||||
case "trigger":
|
||||
if (!schedulerId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "schedulerId is required for trigger action",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await integration.triggerScheduler(schedulerId);
|
||||
break;
|
||||
|
||||
case "startAll":
|
||||
await integration.getManager().startAll();
|
||||
break;
|
||||
|
||||
case "stopAll":
|
||||
await integration.getManager().stopAll();
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Action '${action}' completed successfully`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Scheduler Management API] POST Error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -22,8 +22,8 @@ function convertToMockChatSession(
|
||||
sentiment: string | null;
|
||||
messagesSent: number | null;
|
||||
avgResponseTime: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
escalated: boolean | null;
|
||||
forwardedHr: boolean | null;
|
||||
initialMsg: string | null;
|
||||
fullTranscriptUrl: string | null;
|
||||
summary: string | null;
|
||||
|
||||
@ -17,8 +17,8 @@ function mapPrismaSessionToChatSession(prismaSession: {
|
||||
sentiment: string | null;
|
||||
messagesSent: number | null;
|
||||
avgResponseTime: number | null;
|
||||
escalated: boolean;
|
||||
forwardedHr: boolean;
|
||||
escalated: boolean | null;
|
||||
forwardedHr: boolean | null;
|
||||
initialMsg: string | null;
|
||||
fullTranscriptUrl: string | null;
|
||||
summary: string | null;
|
||||
@ -55,8 +55,8 @@ function mapPrismaSessionToChatSession(prismaSession: {
|
||||
sentiment: prismaSession.sentiment ?? null,
|
||||
messagesSent: prismaSession.messagesSent ?? undefined, // Maintain consistency with other nullable fields
|
||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||
escalated: prismaSession.escalated,
|
||||
forwardedHr: prismaSession.forwardedHr,
|
||||
escalated: prismaSession.escalated ?? false,
|
||||
forwardedHr: prismaSession.forwardedHr ?? false,
|
||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? undefined,
|
||||
summary: prismaSession.summary ?? undefined, // New field
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { CompanyStatus } from "@prisma/client";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getServerSession, type Session } from "next-auth";
|
||||
import { platformAuthOptions } from "../../../../lib/platform-auth";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { extractClientIP } from "../../../../lib/rateLimiter";
|
||||
@ -12,7 +12,7 @@ import {
|
||||
|
||||
// GET /api/platform/companies - List all companies
|
||||
export async function GET(request: NextRequest) {
|
||||
let session: any = null;
|
||||
let session: Session | null = null;
|
||||
|
||||
try {
|
||||
session = await getServerSession(platformAuthOptions);
|
||||
@ -139,7 +139,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/platform/companies - Create new company
|
||||
export async function POST(request: NextRequest) {
|
||||
let session: any = null;
|
||||
let session: Session | null = null;
|
||||
|
||||
try {
|
||||
session = await getServerSession(platformAuthOptions);
|
||||
@ -229,7 +229,7 @@ export async function POST(request: NextRequest) {
|
||||
name: adminName,
|
||||
role: "ADMIN",
|
||||
companyId: company.id,
|
||||
invitedBy: session.user.email || "platform",
|
||||
invitedBy: session?.user?.email || "platform",
|
||||
invitedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -15,7 +15,7 @@ export default function CompanySettingsPage() {
|
||||
const csvUsernameId = useId();
|
||||
const csvPasswordId = useId();
|
||||
const { data: session, status } = useSession();
|
||||
const [_company, setCompany] = useState<Company | null>(null);
|
||||
const [, setCompany] = useState<Company | null>(null);
|
||||
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||
|
||||
@ -470,7 +470,7 @@ function DashboardContent() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||
const [company, _setCompany] = useState<Company | null>(null);
|
||||
const [company] = useState<Company | null>(null);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
||||
|
||||
@ -505,27 +505,28 @@ function DashboardContent() {
|
||||
avgSessionLength: null,
|
||||
days: {},
|
||||
languages: {},
|
||||
categories: {},
|
||||
countries: {},
|
||||
belowThresholdCount: 0,
|
||||
// Map sentiment data to individual counts
|
||||
sentimentPositiveCount:
|
||||
overviewData.sentimentDistribution?.find(
|
||||
(s) => s.sentiment === "positive"
|
||||
(s) => s.sentiment === "POSITIVE"
|
||||
)?.count || 0,
|
||||
sentimentNeutralCount:
|
||||
overviewData.sentimentDistribution?.find(
|
||||
(s) => s.sentiment === "neutral"
|
||||
(s) => s.sentiment === "NEUTRAL"
|
||||
)?.count || 0,
|
||||
sentimentNegativeCount:
|
||||
overviewData.sentimentDistribution?.find(
|
||||
(s) => s.sentiment === "negative"
|
||||
(s) => s.sentiment === "NEGATIVE"
|
||||
)?.count || 0,
|
||||
// Map category data to CategoryMetrics format
|
||||
...(overviewData.categoryDistribution && {
|
||||
categories: overviewData.categoryDistribution.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.category] = item.count;
|
||||
if (item.category) {
|
||||
acc[item.category] = item.count;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
|
||||
@ -32,21 +32,21 @@ interface FilterOptions {
|
||||
|
||||
interface FilterSectionProps {
|
||||
filtersExpanded: boolean;
|
||||
setFiltersExpanded: (_expanded: boolean) => void;
|
||||
setFiltersExpanded: (expanded: boolean) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (_term: string) => void;
|
||||
setSearchTerm: (term: string) => void;
|
||||
selectedCategory: string;
|
||||
setSelectedCategory: (_category: string) => void;
|
||||
setSelectedCategory: (category: string) => void;
|
||||
selectedLanguage: string;
|
||||
setSelectedLanguage: (_language: string) => void;
|
||||
setSelectedLanguage: (language: string) => void;
|
||||
startDate: string;
|
||||
setStartDate: (_date: string) => void;
|
||||
setStartDate: (date: string) => void;
|
||||
endDate: string;
|
||||
setEndDate: (_date: string) => void;
|
||||
setEndDate: (date: string) => void;
|
||||
sortKey: string;
|
||||
setSortKey: (_key: string) => void;
|
||||
setSortKey: (key: string) => void;
|
||||
sortOrder: string;
|
||||
setSortOrder: (_order: string) => void;
|
||||
setSortOrder: (order: string) => void;
|
||||
filterOptions: FilterOptions;
|
||||
searchHeadingId: string;
|
||||
searchId: string;
|
||||
@ -392,7 +392,7 @@ function SessionList({
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (_page: number | ((_prev: number) => number)) => void;
|
||||
setCurrentPage: (page: number | ((prev: number) => number)) => void;
|
||||
}
|
||||
|
||||
function Pagination({
|
||||
|
||||
@ -505,7 +505,7 @@ export default function CompanyManagement() {
|
||||
}
|
||||
|
||||
fetchCompany();
|
||||
}, [status, session?.user?.isPlatformUser, fetchCompany, router.push]);
|
||||
}, [status, session?.user?.isPlatformUser, fetchCompany, router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
state.setIsSaving(true);
|
||||
|
||||
@ -141,7 +141,7 @@ function usePlatformDashboardState() {
|
||||
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||
const [copiedPassword, setCopiedPassword] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [newCompanyData, setNewCompanyData] = useState({
|
||||
const [newCompanyData, setNewCompanyData] = useState<NewCompanyData>({
|
||||
name: "",
|
||||
csvUrl: "",
|
||||
csvUsername: "",
|
||||
@ -202,9 +202,7 @@ function useFormIds() {
|
||||
*/
|
||||
function renderCompanyFormFields(
|
||||
newCompanyData: NewCompanyData,
|
||||
setNewCompanyData: (
|
||||
updater: (prev: NewCompanyData) => NewCompanyData
|
||||
) => void,
|
||||
setNewCompanyData: React.Dispatch<React.SetStateAction<NewCompanyData>>,
|
||||
formIds: FormIds
|
||||
) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user