mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 14:12: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:
71
lib/repositories/BaseRepository.ts
Normal file
71
lib/repositories/BaseRepository.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Base repository interface with common CRUD operations
|
||||
*/
|
||||
export interface BaseRepository<T, ID = string> {
|
||||
findById(id: ID): Promise<T | null>;
|
||||
findMany(options?: FindManyOptions<T>): Promise<T[]>;
|
||||
create(data: CreateInput<T>): Promise<T>;
|
||||
update(id: ID, data: UpdateInput<T>): Promise<T | null>;
|
||||
delete(id: ID): Promise<boolean>;
|
||||
count(options?: CountOptions<T>): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic find options interface
|
||||
*/
|
||||
export interface FindManyOptions<T> {
|
||||
where?: Partial<T>;
|
||||
orderBy?: Record<keyof T, "asc" | "desc">;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
include?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic count options interface
|
||||
*/
|
||||
export interface CountOptions<T> {
|
||||
where?: Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create input type - excludes auto-generated fields
|
||||
*/
|
||||
export type CreateInput<T> = Omit<T, "id" | "createdAt" | "updatedAt">;
|
||||
|
||||
/**
|
||||
* Update input type - excludes auto-generated fields and makes all optional
|
||||
*/
|
||||
export type UpdateInput<T> = Partial<Omit<T, "id" | "createdAt" | "updatedAt">>;
|
||||
|
||||
/**
|
||||
* Repository error types
|
||||
*/
|
||||
export class RepositoryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RepositoryError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends RepositoryError {
|
||||
constructor(entity: string, id: string | number) {
|
||||
super(`${entity} with id ${id} not found`, "NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends RepositoryError {
|
||||
constructor(message: string, cause?: Error) {
|
||||
super(message, "CONFLICT", cause);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends RepositoryError {
|
||||
constructor(message: string, cause?: Error) {
|
||||
super(message, "VALIDATION_ERROR", cause);
|
||||
}
|
||||
}
|
||||
92
lib/repositories/RepositoryFactory.ts
Normal file
92
lib/repositories/RepositoryFactory.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { SecurityAuditLogRepository } from "./SecurityAuditLogRepository";
|
||||
import { SessionRepository } from "./SessionRepository";
|
||||
import { UserRepository } from "./UserRepository";
|
||||
|
||||
/**
|
||||
* Repository factory for centralized repository management
|
||||
* Implements singleton pattern to ensure single instances
|
||||
*/
|
||||
export class RepositoryFactory {
|
||||
private static instance: RepositoryFactory;
|
||||
|
||||
private sessionRepository?: SessionRepository;
|
||||
private userRepository?: UserRepository;
|
||||
private securityAuditLogRepository?: SecurityAuditLogRepository;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of RepositoryFactory
|
||||
*/
|
||||
static getInstance(): RepositoryFactory {
|
||||
if (!RepositoryFactory.instance) {
|
||||
RepositoryFactory.instance = new RepositoryFactory();
|
||||
}
|
||||
return RepositoryFactory.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SessionRepository instance
|
||||
*/
|
||||
getSessionRepository(): SessionRepository {
|
||||
if (!this.sessionRepository) {
|
||||
this.sessionRepository = new SessionRepository();
|
||||
}
|
||||
return this.sessionRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UserRepository instance
|
||||
*/
|
||||
getUserRepository(): UserRepository {
|
||||
if (!this.userRepository) {
|
||||
this.userRepository = new UserRepository();
|
||||
}
|
||||
return this.userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SecurityAuditLogRepository instance
|
||||
*/
|
||||
getSecurityAuditLogRepository(): SecurityAuditLogRepository {
|
||||
if (!this.securityAuditLogRepository) {
|
||||
this.securityAuditLogRepository = new SecurityAuditLogRepository();
|
||||
}
|
||||
return this.securityAuditLogRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repository instances
|
||||
*/
|
||||
getAllRepositories() {
|
||||
return {
|
||||
sessions: this.getSessionRepository(),
|
||||
users: this.getUserRepository(),
|
||||
securityAuditLogs: this.getSecurityAuditLogRepository(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all repository instances (useful for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
this.sessionRepository = undefined;
|
||||
this.userRepository = undefined;
|
||||
this.securityAuditLogRepository = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get repository factory instance
|
||||
*/
|
||||
export const repositories = RepositoryFactory.getInstance();
|
||||
|
||||
/**
|
||||
* Convenience functions to get specific repositories
|
||||
*/
|
||||
export const getSessionRepository = () => repositories.getSessionRepository();
|
||||
export const getUserRepository = () => repositories.getUserRepository();
|
||||
export const getSecurityAuditLogRepository = () =>
|
||||
repositories.getSecurityAuditLogRepository();
|
||||
476
lib/repositories/SecurityAuditLogRepository.ts
Normal file
476
lib/repositories/SecurityAuditLogRepository.ts
Normal file
@ -0,0 +1,476 @@
|
||||
import type { Prisma, SecurityAuditLog } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import {
|
||||
AuditOutcome,
|
||||
type AuditSeverity,
|
||||
SecurityEventType,
|
||||
} from "../securityAuditLogger";
|
||||
import {
|
||||
type BaseRepository,
|
||||
type CountOptions,
|
||||
type CreateInput,
|
||||
type FindManyOptions,
|
||||
RepositoryError,
|
||||
type UpdateInput,
|
||||
} from "./BaseRepository";
|
||||
|
||||
/**
|
||||
* Security audit log with included relations
|
||||
*/
|
||||
export type SecurityAuditLogWithRelations = SecurityAuditLog & {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Security audit analytics interface
|
||||
*/
|
||||
export interface SecurityAnalytics {
|
||||
totalEvents: number;
|
||||
eventsByType: Record<SecurityEventType, number>;
|
||||
eventsBySeverity: Record<AuditSeverity, number>;
|
||||
eventsByOutcome: Record<AuditOutcome, number>;
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topUsers: Array<{ userId: string; email: string; count: number }>;
|
||||
hourlyDistribution: Array<{ hour: number; count: number }>;
|
||||
geoDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SecurityAuditLog repository implementing database operations
|
||||
*/
|
||||
export class SecurityAuditLogRepository
|
||||
implements BaseRepository<SecurityAuditLog>
|
||||
{
|
||||
/**
|
||||
* Find audit log by ID
|
||||
*/
|
||||
async findById(id: string): Promise<SecurityAuditLogWithRelations | null> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true },
|
||||
},
|
||||
company: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find audit log ${id}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many audit logs with filters
|
||||
*/
|
||||
async findMany(
|
||||
options?: FindManyOptions<SecurityAuditLog>
|
||||
): Promise<SecurityAuditLogWithRelations[]> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.findMany({
|
||||
where: options?.where as Prisma.SecurityAuditLogWhereInput,
|
||||
orderBy:
|
||||
options?.orderBy as Prisma.SecurityAuditLogOrderByWithRelationInput,
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true },
|
||||
},
|
||||
company: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find audit logs",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find audit logs by event type
|
||||
*/
|
||||
async findByEventType(
|
||||
eventType: SecurityEventType,
|
||||
limit = 100
|
||||
): Promise<SecurityAuditLog[]> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.findMany({
|
||||
where: { eventType },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find audit logs by event type ${eventType}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find audit logs by IP address within time range
|
||||
*/
|
||||
async findByIPAddress(
|
||||
ipAddress: string,
|
||||
startTime: Date,
|
||||
endTime?: Date
|
||||
): Promise<SecurityAuditLog[]> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.findMany({
|
||||
where: {
|
||||
ipAddress,
|
||||
timestamp: {
|
||||
gte: startTime,
|
||||
...(endTime && { lte: endTime }),
|
||||
},
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find audit logs by IP ${ipAddress}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find failed authentication attempts
|
||||
*/
|
||||
async findFailedAuthAttempts(
|
||||
ipAddress?: string,
|
||||
timeWindow = 24 * 60 * 60 * 1000 // 24 hours in ms
|
||||
): Promise<SecurityAuditLog[]> {
|
||||
try {
|
||||
const startTime = new Date(Date.now() - timeWindow);
|
||||
return await prisma.securityAuditLog.findMany({
|
||||
where: {
|
||||
eventType: SecurityEventType.AUTHENTICATION,
|
||||
outcome: AuditOutcome.FAILURE,
|
||||
timestamp: { gte: startTime },
|
||||
...(ipAddress && { ipAddress }),
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find failed authentication attempts",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create audit log entry
|
||||
*/
|
||||
async create(data: CreateInput<SecurityAuditLog>): Promise<SecurityAuditLog> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.create({
|
||||
data: data as Prisma.SecurityAuditLogCreateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to create audit log",
|
||||
"CREATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update audit log (rarely used, mainly for corrections)
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateInput<SecurityAuditLog>
|
||||
): Promise<SecurityAuditLog | null> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.update({
|
||||
where: { id },
|
||||
data: data as Prisma.SecurityAuditLogUpdateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to update audit log ${id}`,
|
||||
"UPDATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete audit log (used for cleanup)
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
try {
|
||||
await prisma.securityAuditLog.delete({ where: { id } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to delete audit log ${id}`,
|
||||
"DELETE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count audit logs with filters
|
||||
*/
|
||||
async count(options?: CountOptions<SecurityAuditLog>): Promise<number> {
|
||||
try {
|
||||
return await prisma.securityAuditLog.count({
|
||||
where: options?.where as Prisma.SecurityAuditLogWhereInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to count audit logs",
|
||||
"COUNT_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security analytics for dashboard
|
||||
*/
|
||||
async getSecurityAnalytics(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
companyId?: string
|
||||
): Promise<SecurityAnalytics> {
|
||||
try {
|
||||
const whereClause = {
|
||||
timestamp: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
...(companyId && { companyId }),
|
||||
};
|
||||
|
||||
const [events, eventsByType, eventsBySeverity, eventsByOutcome] =
|
||||
await Promise.all([
|
||||
prisma.securityAuditLog.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
user: { select: { id: true, email: true } },
|
||||
},
|
||||
}),
|
||||
prisma.securityAuditLog.groupBy({
|
||||
by: ["eventType"],
|
||||
where: whereClause,
|
||||
_count: { eventType: true },
|
||||
}),
|
||||
prisma.securityAuditLog.groupBy({
|
||||
by: ["severity"],
|
||||
where: whereClause,
|
||||
_count: { severity: true },
|
||||
}),
|
||||
prisma.securityAuditLog.groupBy({
|
||||
by: ["outcome"],
|
||||
where: whereClause,
|
||||
_count: { outcome: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Process aggregated data
|
||||
const totalEvents = events.length;
|
||||
|
||||
const eventsByTypeMap = eventsByType.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.eventType as SecurityEventType] = item._count.eventType;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SecurityEventType, number>
|
||||
);
|
||||
|
||||
const eventsBySeverityMap = eventsBySeverity.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.severity as AuditSeverity] = item._count.severity;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AuditSeverity, number>
|
||||
);
|
||||
|
||||
const eventsByOutcomeMap = eventsByOutcome.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.outcome as AuditOutcome] = item._count.outcome;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AuditOutcome, number>
|
||||
);
|
||||
|
||||
// Top IPs
|
||||
const ipCounts = events.reduce(
|
||||
(acc, event) => {
|
||||
if (event.ipAddress) {
|
||||
acc[event.ipAddress] = (acc[event.ipAddress] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const topIPs = Object.entries(ipCounts)
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Top users
|
||||
const userCounts = events
|
||||
.filter((e) => e.userId && e.user)
|
||||
.reduce(
|
||||
(acc, event) => {
|
||||
const key = event.userId!;
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
userId: event.userId!,
|
||||
email: event.user?.email,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
acc[key].count++;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { userId: string; email: string; count: number }>
|
||||
);
|
||||
|
||||
const topUsers = Object.values(userCounts)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Hourly distribution
|
||||
const hourlyDistribution = Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
count: events.filter((e) => e.timestamp.getHours() === hour).length,
|
||||
}));
|
||||
|
||||
// Geographic distribution
|
||||
const geoDistribution = events.reduce(
|
||||
(acc, event) => {
|
||||
if (event.country) {
|
||||
acc[event.country] = (acc[event.country] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
totalEvents,
|
||||
eventsByType: eventsByTypeMap,
|
||||
eventsBySeverity: eventsBySeverityMap,
|
||||
eventsByOutcome: eventsByOutcomeMap,
|
||||
topIPs,
|
||||
topUsers,
|
||||
hourlyDistribution,
|
||||
geoDistribution,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to get security analytics",
|
||||
"ANALYTICS_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old audit logs based on retention policy
|
||||
*/
|
||||
async cleanupOldLogs(retentionDays: number): Promise<number> {
|
||||
try {
|
||||
const cutoffDate = new Date(
|
||||
Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
const result = await prisma.securityAuditLog.deleteMany({
|
||||
where: {
|
||||
timestamp: { lt: cutoffDate },
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to cleanup old audit logs",
|
||||
"CLEANUP_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspicious activity summary for an IP
|
||||
*/
|
||||
async getIPActivitySummary(
|
||||
ipAddress: string,
|
||||
hoursBack = 24
|
||||
): Promise<{
|
||||
failedLogins: number;
|
||||
rateLimitViolations: number;
|
||||
uniqueUsersTargeted: number;
|
||||
totalEvents: number;
|
||||
timeSpan: { first: Date | null; last: Date | null };
|
||||
}> {
|
||||
try {
|
||||
const startTime = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
|
||||
|
||||
const events = await this.findByIPAddress(ipAddress, startTime);
|
||||
|
||||
const failedLogins = events.filter(
|
||||
(e) =>
|
||||
e.eventType === SecurityEventType.AUTHENTICATION &&
|
||||
e.outcome === AuditOutcome.FAILURE
|
||||
).length;
|
||||
|
||||
const rateLimitViolations = events.filter(
|
||||
(e) => e.outcome === AuditOutcome.RATE_LIMITED
|
||||
).length;
|
||||
|
||||
const uniqueUsersTargeted = new Set(
|
||||
events.map((e) => e.userId).filter(Boolean)
|
||||
).size;
|
||||
|
||||
const timeSpan = {
|
||||
first: events.length > 0 ? events[events.length - 1].timestamp : null,
|
||||
last: events.length > 0 ? events[0].timestamp : null,
|
||||
};
|
||||
|
||||
return {
|
||||
failedLogins,
|
||||
rateLimitViolations,
|
||||
uniqueUsersTargeted,
|
||||
totalEvents: events.length,
|
||||
timeSpan,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to get IP activity summary for ${ipAddress}`,
|
||||
"ACTIVITY_SUMMARY_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
lib/repositories/SessionRepository.ts
Normal file
335
lib/repositories/SessionRepository.ts
Normal file
@ -0,0 +1,335 @@
|
||||
import type { Prisma, Session } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import {
|
||||
type BaseRepository,
|
||||
type CountOptions,
|
||||
type CreateInput,
|
||||
type FindManyOptions,
|
||||
NotFoundError,
|
||||
RepositoryError,
|
||||
type UpdateInput,
|
||||
} from "./BaseRepository";
|
||||
|
||||
/**
|
||||
* Session with included relations
|
||||
*/
|
||||
export type SessionWithRelations = Session & {
|
||||
messages?: Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
timestamp: Date | null;
|
||||
role: string;
|
||||
content: string;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
sessionImport?: {
|
||||
id: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Session repository implementing database operations
|
||||
*/
|
||||
export class SessionRepository implements BaseRepository<Session> {
|
||||
/**
|
||||
* Find session by ID with optional relations
|
||||
*/
|
||||
async findById(
|
||||
id: string,
|
||||
include?: { messages?: boolean; company?: boolean; sessionImport?: boolean }
|
||||
): Promise<SessionWithRelations | null> {
|
||||
try {
|
||||
return await prisma.session.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
messages: include?.messages
|
||||
? { orderBy: { order: "asc" } }
|
||||
: undefined,
|
||||
company: include?.company
|
||||
? { select: { id: true, name: true } }
|
||||
: undefined,
|
||||
sessionImport: include?.sessionImport
|
||||
? { select: { id: true, status: true } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find session ${id}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sessions by company ID
|
||||
*/
|
||||
async findByCompanyId(
|
||||
companyId: string,
|
||||
options?: Omit<FindManyOptions<Session>, "where">
|
||||
): Promise<Session[]> {
|
||||
try {
|
||||
return await prisma.session.findMany({
|
||||
where: { companyId },
|
||||
orderBy: options?.orderBy as Prisma.SessionOrderByWithRelationInput,
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: options?.include as Prisma.SessionInclude,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find sessions for company ${companyId}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sessions by date range
|
||||
*/
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
companyId?: string
|
||||
): Promise<Session[]> {
|
||||
try {
|
||||
return await prisma.session.findMany({
|
||||
where: {
|
||||
startTime: {
|
||||
gte: startDate,
|
||||
lte: endDate,
|
||||
},
|
||||
...(companyId && { companyId }),
|
||||
},
|
||||
orderBy: { startTime: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find sessions by date range",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many sessions with filters
|
||||
*/
|
||||
async findMany(options?: FindManyOptions<Session>): Promise<Session[]> {
|
||||
try {
|
||||
return await prisma.session.findMany({
|
||||
where: options?.where as Prisma.SessionWhereInput,
|
||||
orderBy: options?.orderBy as Prisma.SessionOrderByWithRelationInput,
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: options?.include as Prisma.SessionInclude,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find sessions",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
async create(data: CreateInput<Session>): Promise<Session> {
|
||||
try {
|
||||
return await prisma.session.create({
|
||||
data: data as Prisma.SessionCreateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to create session",
|
||||
"CREATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session by ID
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
data: UpdateInput<Session>
|
||||
): Promise<Session | null> {
|
||||
try {
|
||||
const session = await this.findById(id);
|
||||
if (!session) {
|
||||
throw new NotFoundError("Session", id);
|
||||
}
|
||||
|
||||
return await prisma.session.update({
|
||||
where: { id },
|
||||
data: data as Prisma.SessionUpdateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
throw new RepositoryError(
|
||||
`Failed to update session ${id}`,
|
||||
"UPDATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session by ID
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
try {
|
||||
const session = await this.findById(id);
|
||||
if (!session) {
|
||||
throw new NotFoundError("Session", id);
|
||||
}
|
||||
|
||||
await prisma.session.delete({ where: { id } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
throw new RepositoryError(
|
||||
`Failed to delete session ${id}`,
|
||||
"DELETE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count sessions with optional filters
|
||||
*/
|
||||
async count(options?: CountOptions<Session>): Promise<number> {
|
||||
try {
|
||||
return await prisma.session.count({
|
||||
where: options?.where as Prisma.SessionWhereInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to count sessions",
|
||||
"COUNT_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session metrics for a company
|
||||
*/
|
||||
async getSessionMetrics(
|
||||
companyId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<{
|
||||
totalSessions: number;
|
||||
avgSessionLength: number | null;
|
||||
sentimentDistribution: Record<string, number>;
|
||||
categoryDistribution: Record<string, number>;
|
||||
}> {
|
||||
try {
|
||||
const sessions = await this.findByDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
companyId
|
||||
);
|
||||
|
||||
const totalSessions = sessions.length;
|
||||
const avgSessionLength =
|
||||
sessions.length > 0
|
||||
? sessions
|
||||
.filter((s) => s.endTime)
|
||||
.reduce((sum, s) => {
|
||||
const duration = s.endTime
|
||||
? (s.endTime.getTime() - s.startTime.getTime()) / 1000
|
||||
: 0;
|
||||
return sum + duration;
|
||||
}, 0) / sessions.filter((s) => s.endTime).length
|
||||
: null;
|
||||
|
||||
const sentimentDistribution = sessions.reduce(
|
||||
(acc, session) => {
|
||||
const sentiment = session.sentiment || "unknown";
|
||||
acc[sentiment] = (acc[sentiment] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const categoryDistribution = sessions.reduce(
|
||||
(acc, session) => {
|
||||
const category = session.category || "uncategorized";
|
||||
acc[category] = (acc[category] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
avgSessionLength,
|
||||
sentimentDistribution,
|
||||
categoryDistribution,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to get session metrics",
|
||||
"METRICS_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sessions needing AI processing
|
||||
*/
|
||||
async findPendingAIProcessing(limit = 100): Promise<Session[]> {
|
||||
try {
|
||||
return await prisma.session.findMany({
|
||||
where: {
|
||||
OR: [{ sentiment: null }, { category: null }, { summary: null }],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find sessions pending AI processing",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update sessions
|
||||
*/
|
||||
async bulkUpdate(
|
||||
where: Prisma.SessionWhereInput,
|
||||
data: Prisma.SessionUpdateInput
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await prisma.session.updateMany({
|
||||
where,
|
||||
data,
|
||||
});
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to bulk update sessions",
|
||||
"BULK_UPDATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
396
lib/repositories/UserRepository.ts
Normal file
396
lib/repositories/UserRepository.ts
Normal file
@ -0,0 +1,396 @@
|
||||
import type { Prisma, User } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import {
|
||||
type BaseRepository,
|
||||
type CountOptions,
|
||||
type CreateInput,
|
||||
type FindManyOptions,
|
||||
NotFoundError,
|
||||
RepositoryError,
|
||||
type UpdateInput,
|
||||
} from "./BaseRepository";
|
||||
|
||||
/**
|
||||
* User with included relations
|
||||
*/
|
||||
export type UserWithRelations = User & {
|
||||
company?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
securityAuditLogs?: Array<{
|
||||
id: string;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
outcome: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* User repository implementing database operations
|
||||
*/
|
||||
export class UserRepository implements BaseRepository<User> {
|
||||
/**
|
||||
* Find user by ID with optional relations
|
||||
*/
|
||||
async findById(
|
||||
id: string,
|
||||
include?: { company?: boolean; securityAuditLogs?: boolean }
|
||||
): Promise<UserWithRelations | null> {
|
||||
try {
|
||||
return await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
company: include?.company
|
||||
? { select: { id: true, name: true } }
|
||||
: undefined,
|
||||
securityAuditLogs: include?.securityAuditLogs
|
||||
? {
|
||||
select: {
|
||||
id: true,
|
||||
eventType: true,
|
||||
timestamp: true,
|
||||
outcome: true,
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { timestamp: "desc" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find user ${id}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
try {
|
||||
return await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find user by email ${email}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users by company ID
|
||||
*/
|
||||
async findByCompanyId(companyId: string): Promise<User[]> {
|
||||
try {
|
||||
return await prisma.user.findMany({
|
||||
where: { companyId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find users by company ${companyId}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users by role
|
||||
*/
|
||||
async findByRole(role: string, companyId?: string): Promise<User[]> {
|
||||
try {
|
||||
return await prisma.user.findMany({
|
||||
where: {
|
||||
role,
|
||||
...(companyId && { companyId }),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to find users by role ${role}`,
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many users with filters
|
||||
*/
|
||||
async findMany(options?: FindManyOptions<User>): Promise<User[]> {
|
||||
try {
|
||||
return await prisma.user.findMany({
|
||||
where: options?.where as Prisma.UserWhereInput,
|
||||
orderBy: options?.orderBy as Prisma.UserOrderByWithRelationInput,
|
||||
skip: options?.skip,
|
||||
take: options?.take,
|
||||
include: options?.include as Prisma.UserInclude,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find users",
|
||||
"FIND_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async create(data: CreateInput<User>): Promise<User> {
|
||||
try {
|
||||
return await prisma.user.create({
|
||||
data: data as Prisma.UserCreateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to create user",
|
||||
"CREATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user by ID
|
||||
*/
|
||||
async update(id: string, data: UpdateInput<User>): Promise<User | null> {
|
||||
try {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", id);
|
||||
}
|
||||
|
||||
return await prisma.user.update({
|
||||
where: { id },
|
||||
data: data as Prisma.UserUpdateInput,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
throw new RepositoryError(
|
||||
`Failed to update user ${id}`,
|
||||
"UPDATE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user by ID
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await this.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", id);
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
throw new RepositoryError(
|
||||
`Failed to delete user ${id}`,
|
||||
"DELETE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count users with optional filters
|
||||
*/
|
||||
async count(options?: CountOptions<User>): Promise<number> {
|
||||
try {
|
||||
return await prisma.user.count({
|
||||
where: options?.where as Prisma.UserWhereInput,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to count users",
|
||||
"COUNT_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user last login timestamp
|
||||
*/
|
||||
async updateLastLogin(id: string): Promise<User | null> {
|
||||
try {
|
||||
return await this.update(id, {
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to update last login for user ${id}`,
|
||||
"UPDATE_LOGIN_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users with recent security events
|
||||
*/
|
||||
async findUsersWithRecentSecurityEvents(
|
||||
hoursBack = 24,
|
||||
minEvents = 5
|
||||
): Promise<Array<{ user: User; eventCount: number }>> {
|
||||
try {
|
||||
const startTime = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
|
||||
|
||||
const usersWithEvents = await prisma.user.findMany({
|
||||
where: {
|
||||
securityAuditLogs: {
|
||||
some: {
|
||||
timestamp: { gte: startTime },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
securityAuditLogs: {
|
||||
where: {
|
||||
timestamp: { gte: startTime },
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return usersWithEvents
|
||||
.map((user) => ({
|
||||
user: {
|
||||
...user,
|
||||
securityAuditLogs: undefined, // Remove from result
|
||||
} as User,
|
||||
eventCount: user.securityAuditLogs?.length || 0,
|
||||
}))
|
||||
.filter((item) => item.eventCount >= minEvents)
|
||||
.sort((a, b) => b.eventCount - a.eventCount);
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find users with recent security events",
|
||||
"SECURITY_EVENTS_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user activity summary
|
||||
*/
|
||||
async getUserActivitySummary(
|
||||
userId: string,
|
||||
hoursBack = 24
|
||||
): Promise<{
|
||||
totalEvents: number;
|
||||
failedLogins: number;
|
||||
successfulLogins: number;
|
||||
rateLimitViolations: number;
|
||||
lastActivity: Date | null;
|
||||
countriesAccessed: string[];
|
||||
}> {
|
||||
try {
|
||||
const startTime = new Date(Date.now() - hoursBack * 60 * 60 * 1000);
|
||||
|
||||
const events = await prisma.securityAuditLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
timestamp: { gte: startTime },
|
||||
},
|
||||
orderBy: { timestamp: "desc" },
|
||||
});
|
||||
|
||||
const totalEvents = events.length;
|
||||
const failedLogins = events.filter(
|
||||
(e) => e.eventType === "AUTHENTICATION" && e.outcome === "FAILURE"
|
||||
).length;
|
||||
const successfulLogins = events.filter(
|
||||
(e) => e.eventType === "AUTHENTICATION" && e.outcome === "SUCCESS"
|
||||
).length;
|
||||
const rateLimitViolations = events.filter(
|
||||
(e) => e.outcome === "RATE_LIMITED"
|
||||
).length;
|
||||
const lastActivity = events.length > 0 ? events[0].timestamp : null;
|
||||
const countriesAccessed = [
|
||||
...new Set(events.map((e) => e.country).filter(Boolean)),
|
||||
];
|
||||
|
||||
return {
|
||||
totalEvents,
|
||||
failedLogins,
|
||||
successfulLogins,
|
||||
rateLimitViolations,
|
||||
lastActivity,
|
||||
countriesAccessed,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to get activity summary for user ${userId}`,
|
||||
"ACTIVITY_SUMMARY_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find inactive users (no login for specified days)
|
||||
*/
|
||||
async findInactiveUsers(daysInactive = 30): Promise<User[]> {
|
||||
try {
|
||||
const cutoffDate = new Date(
|
||||
Date.now() - daysInactive * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
return await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ lastLoginAt: { lt: cutoffDate } }, { lastLoginAt: null }],
|
||||
},
|
||||
orderBy: { lastLoginAt: "asc" },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
"Failed to find inactive users",
|
||||
"FIND_INACTIVE_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by name or email
|
||||
*/
|
||||
async searchUsers(query: string, companyId?: string): Promise<User[]> {
|
||||
try {
|
||||
return await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ email: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
...(companyId && { companyId }),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 50, // Limit results
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RepositoryError(
|
||||
`Failed to search users with query "${query}"`,
|
||||
"SEARCH_ERROR",
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user