202 lines
5.2 KiB
TypeScript
202 lines
5.2 KiB
TypeScript
// src/services/session.ts
|
|
// Copyright (C) 2026 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
import crypto from "crypto";
|
|
import dayjs from "dayjs";
|
|
|
|
import { DtpService } from "../lib/service.js";
|
|
import Session, { ISession } from "../models/session.js";
|
|
import User from "../models/user.js";
|
|
import ChatSession from "../models/chat-session.js";
|
|
|
|
export interface SessionOptions {
|
|
expiresHours: number;
|
|
maxInactiveHours: number;
|
|
}
|
|
|
|
export class SessionService extends DtpService {
|
|
get name(): string {
|
|
return "SessionService";
|
|
}
|
|
get slug(): string {
|
|
return "session";
|
|
}
|
|
|
|
private defaultExpiresHours = 24;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.log.info("service started");
|
|
// Start session cleanup cron job
|
|
this.startCleanupJob();
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
this.log.info("service stopped");
|
|
}
|
|
|
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
|
|
private startCleanupJob(): void {
|
|
// Clean up expired sessions every hour
|
|
this.cleanupInterval = setInterval(
|
|
async () => {
|
|
try {
|
|
const result = await Session.deleteMany({
|
|
expiresAt: { $lt: new Date() },
|
|
});
|
|
if (result.deletedCount > 0) {
|
|
this.log.info(`Cleaned up ${result.deletedCount} expired sessions`);
|
|
}
|
|
} catch (error) {
|
|
this.log.error("Session cleanup failed", { error });
|
|
}
|
|
},
|
|
60 * 60 * 1000,
|
|
); // 1 hour
|
|
}
|
|
|
|
async create(
|
|
userId: string,
|
|
ipAddress?: string,
|
|
userAgent?: string,
|
|
options?: SessionOptions,
|
|
): Promise<ISession> {
|
|
const NOW = new Date();
|
|
const expiresHours = options?.expiresHours || this.defaultExpiresHours;
|
|
|
|
const session = new Session();
|
|
session.userId = new (await import("mongoose")).Types.ObjectId(userId);
|
|
session.token = crypto.randomBytes(64).toString("hex");
|
|
session.createdAt = NOW;
|
|
session.expiresAt = dayjs(NOW).add(expiresHours, "hour").toDate();
|
|
session.lastActivityAt = NOW;
|
|
session.ipAddress = ipAddress;
|
|
session.userAgent = userAgent;
|
|
|
|
await session.save();
|
|
this.log.debug("Session created", { userId, sessionId: session._id });
|
|
|
|
return session;
|
|
}
|
|
|
|
async findById(sessionId: string): Promise<ISession | null> {
|
|
const session = await Session.findById(sessionId).populate("userId");
|
|
if (!session) {
|
|
this.log.debug("Session not found", { sessionId });
|
|
return null;
|
|
}
|
|
|
|
// Check if session is expired
|
|
if (session.expiresAt < new Date()) {
|
|
this.log.debug("Session expired", { sessionId: session._id });
|
|
await Session.deleteOne({ _id: session._id });
|
|
return null;
|
|
}
|
|
|
|
// Update last activity
|
|
session.lastActivityAt = new Date();
|
|
await session.save();
|
|
|
|
this.log.debug("Session found", { sessionId: session._id });
|
|
return session;
|
|
}
|
|
|
|
async findByToken(token: string): Promise<ISession | null> {
|
|
const session = await Session.findOne({ token }).populate("userId");
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
// Check if session is expired
|
|
if (session.expiresAt < new Date()) {
|
|
this.log.debug("Session expired", { sessionId: session._id });
|
|
await Session.deleteOne({ _id: session._id });
|
|
return null;
|
|
}
|
|
|
|
// Update last activity
|
|
session.lastActivityAt = new Date();
|
|
await session.save();
|
|
|
|
return session;
|
|
}
|
|
|
|
async revoke(token: string): Promise<boolean> {
|
|
const result = await Session.deleteOne({ token });
|
|
if (result.deletedCount > 0) {
|
|
this.log.debug("Session revoked", { token });
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async revokeAllForUser(userId: string): Promise<number> {
|
|
const result = await Session.deleteMany({ userId });
|
|
this.log.debug("Revoked all sessions for user", {
|
|
userId,
|
|
count: result.deletedCount,
|
|
});
|
|
return result.deletedCount;
|
|
}
|
|
|
|
async extend(session: ISession, hours?: number): Promise<ISession> {
|
|
const extendHours = hours || this.defaultExpiresHours;
|
|
session.expiresAt = dayjs(session.expiresAt)
|
|
.add(extendHours, "hour")
|
|
.toDate();
|
|
session.lastActivityAt = new Date();
|
|
await session.save();
|
|
this.log.debug("Session extended", { sessionId: session._id });
|
|
return session;
|
|
}
|
|
|
|
async getUserFromSession(session: ISession) {
|
|
if (!session.userId) {
|
|
return null;
|
|
}
|
|
// userId is already populated from findByToken
|
|
return session.userId as unknown as typeof User.prototype;
|
|
}
|
|
|
|
async listByProject(
|
|
projectId: string,
|
|
userId: string,
|
|
): Promise<
|
|
Array<{
|
|
_id: string;
|
|
name: string;
|
|
lastMessageAt: Date | null;
|
|
turnCount: number;
|
|
toolCallCount: number;
|
|
createdAt: Date;
|
|
}>
|
|
> {
|
|
const sessions = await ChatSession.find({
|
|
user: userId,
|
|
project: projectId,
|
|
})
|
|
.sort({ lastMessageAt: -1, createdAt: -1 })
|
|
.lean();
|
|
|
|
return sessions.map((s) => ({
|
|
_id: s._id.toString(),
|
|
name: s.name,
|
|
lastMessageAt: s.lastMessageAt ?? null,
|
|
turnCount: s.stats.turnCount,
|
|
toolCallCount: s.stats.toolCallCount,
|
|
createdAt: s.createdAt,
|
|
}));
|
|
}
|
|
}
|
|
|
|
export default new SessionService();
|