diff --git a/prisma/migrations/20260422170000_add_session_management.sql b/prisma/migrations/20260422170000_add_session_management.sql new file mode 100644 index 00000000..7dc0f20c --- /dev/null +++ b/prisma/migrations/20260422170000_add_session_management.sql @@ -0,0 +1,28 @@ +-- CreateTable for sessions +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "access_token_jti" TEXT NOT NULL, + "refresh_token_jti" TEXT, + "ip_address" TEXT, + "user_agent" TEXT, + "is_revoked" BOOLEAN NOT NULL DEFAULT false, + "revoked_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_activity_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- CreateIndex +CREATE INDEX "sessions_is_revoked_idx" ON "sessions"("is_revoked"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 503a6946..769bc14e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,6 +84,7 @@ model User { apiKeys ApiKey[] passwordHistory PasswordHistory[] blacklistedTokens BlacklistedToken[] + sessions Session[] @@index([email]) @@index([role]) @@ -225,3 +226,26 @@ model Document { @@index([documentType]) @@map("documents") } + +// Session model for tracking user sessions +model Session { + id String @id @default(uuid()) + userId String @map("user_id") + accessTokenJti String @map("access_token_jti") + refreshTokenJti String? @map("refresh_token_jti") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + isRevoked Boolean @default(false) @map("is_revoked") + revokedAt DateTime? @map("revoked_at") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + lastActivityAt DateTime @default(now()) @map("last_activity_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@index([isRevoked]) + @@map("sessions") +} diff --git a/src/app.module.ts b/src/app.module.ts index 7e0ca7d3..b4f1c5fb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,8 @@ import { PropertiesModule } from './properties/properties.module'; import { PrismaModule } from './database/prisma.module'; import { AppController } from './app.controller'; import { AuthModule } from './auth/auth.module'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { SessionsModule } from './sessions/sessions.module'; @Module({ imports: [ @@ -16,6 +18,8 @@ import { AuthModule } from './auth/auth.module'; UsersModule, PropertiesModule, AuthModule, + DashboardModule, + SessionsModule, ], controllers: [AppController], }) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e29811c7..be5247a9 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../database/prisma.module'; import { UsersModule } from '../users/users.module'; +import { SessionsModule } from '../sessions/sessions.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; @Module({ - imports: [PrismaModule, UsersModule], + imports: [PrismaModule, UsersModule, SessionsModule], controllers: [AuthController], providers: [AuthService, JwtAuthGuard, ApiKeyAuthGuard], exports: [AuthService], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 44342a25..391ab4ba 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,6 +10,7 @@ import { randomUUID } from 'crypto'; import * as jwt from 'jsonwebtoken'; import { PrismaService } from '../database/prisma.service'; import { UsersService } from '../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; import { ChangePasswordDto, CreateApiKeyDto, @@ -54,6 +55,7 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly usersService: UsersService, + private readonly sessionsService: SessionsService, private readonly configService: ConfigService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; @@ -224,6 +226,162 @@ export class AuthService { return sanitizeUser(foundUser); } + async getDashboard(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const [properties, buyerTransactions, sellerTransactions, documents, apiKeys] = await Promise.all([ + this.prisma.property.findMany({ + where: { ownerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.transaction.findMany({ + where: { buyerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, + }, + seller: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.transaction.findMany({ + where: { sellerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, + }, + buyer: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.document.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + this.prisma.apiKey.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 3, + }), + ]); + + const [ + totalProperties, + activeListings, + pendingSales, + totalPurchases, + totalSales, + completedPurchases, + completedSales, + ] = await Promise.all([ + this.prisma.property.count({ where: { ownerId: user.sub } }), + this.prisma.property.count({ where: { ownerId: user.sub, status: 'ACTIVE' } }), + this.prisma.transaction.count({ where: { sellerId: user.sub, status: 'PENDING' } }), + this.prisma.transaction.count({ where: { buyerId: user.sub } }), + this.prisma.transaction.count({ where: { sellerId: user.sub } }), + this.prisma.transaction.count({ where: { buyerId: user.sub, status: 'COMPLETED' } }), + this.prisma.transaction.count({ where: { sellerId: user.sub, status: 'COMPLETED' } }), + ]); + + const recommendationProperties = await this.prisma.property.findMany({ + where: { + status: 'ACTIVE', + ownerId: { not: user.sub }, + NOT: { + ownerId: user.sub, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + owner: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }); + + const recentActivity = [ + ...transactionsToActivityItems(buyerTransactions, 'purchase'), + ...transactionsToActivityItems(sellerTransactions, 'sale'), + ...documents.map((doc) => ({ + type: 'document' as const, + id: doc.id, + title: doc.fileName, + description: `Uploaded ${doc.documentType.toLowerCase().replace('_', ' ')}`, + timestamp: doc.createdAt, + })), + ] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 10); + + return { + profile: sanitizeUser(foundUser), + quickStats: { + totalProperties, + activeListings, + pendingSales, + totalPurchases, + totalSales, + completedPurchases, + completedSales, + apiKeysCount: apiKeys.length, + }, + recentActivity, + recommendations: recommendationProperties.map((p) => ({ + id: p.id, + title: p.title, + address: p.address, + city: p.city, + state: p.state, + price: p.price.toString(), + propertyType: p.propertyType, + bedrooms: p.bedrooms, + bathrooms: p.bathrooms?.toString(), + squareFeet: p.squareFeet?.toString(), + status: p.status, + agent: `${p.owner.firstName} ${p.owner.lastName}`, + createdAt: p.createdAt, + })), + }; + } + async changePassword(user: AuthUserPayload, data: ChangePasswordDto) { const passwordHistoryLimit = getPasswordHistoryLimit(); const existingUser = await this.prisma.user.findUnique({ @@ -506,7 +664,7 @@ export class AuthService { }; } - private async issueTokenPair(user: User) { + private async issueTokenPair(user: User, ipAddress?: string, userAgent?: string) { const accessJti = randomUUID(); const refreshJti = randomUUID(); @@ -532,6 +690,16 @@ export class AuthService { this.refreshTokenTtlSeconds, ); + // Create a session for tracking + await this.sessionsService.createSession( + user.id, + accessJti, + refreshJti, + ipAddress, + userAgent, + this.refreshTokenTtlSeconds, + ); + return { accessToken, refreshToken, diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts new file mode 100644 index 00000000..41b16c7e --- /dev/null +++ b/src/dashboard/dashboard.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { DashboardDto } from './dto/dashboard.dto'; + +@Controller('dashboard') +@UseGuards(JwtAuthGuard) +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get() + async getDashboard(@CurrentUser() user: AuthUserPayload): Promise { + return this.dashboardService.getDashboard(user.sub); + } +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts new file mode 100644 index 00000000..65a0b6a7 --- /dev/null +++ b/src/dashboard/dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DashboardService } from './dashboard.service'; +import { DashboardController } from './dashboard.controller'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts new file mode 100644 index 00000000..11197eca --- /dev/null +++ b/src/dashboard/dashboard.service.ts @@ -0,0 +1,229 @@ +import { Injectable } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../database/prisma.service'; +import { + DashboardDto, + ProfileSummaryDto, + QuickStatsDto, + ActivityItemDto, + RecommendationItemDto, +} from './dto/dashboard.dto'; + +@Injectable() +export class DashboardService { + constructor(private prisma: PrismaService) {} + + async getDashboard(userId: string): Promise { + const [profile, stats, recentActivity, recommendations] = await Promise.all([ + this.getProfileSummary(userId), + this.getQuickStats(userId), + this.getRecentActivity(userId), + this.getRecommendations(userId), + ]); + + return { + profile, + stats, + recentActivity, + recommendations, + }; + } + + private async getProfileSummary(userId: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + role: true, + avatar: true, + isVerified: true, + createdAt: true, + }, + }); + + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + phone: user.phone, + role: user.role, + avatar: user.avatar, + isVerified: user.isVerified, + createdAt: user.createdAt, + memberSince: user.createdAt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }), + }; + } + + private async getQuickStats(userId: string): Promise { + // Get user's properties + const properties = await this.prisma.property.findMany({ + where: { ownerId: userId }, + }); + + const totalProperties = properties.length; + const activeListings = properties.filter((p) => p.status === 'ACTIVE').length; + + // Get user's transactions (both as buyer and seller) + const buyerTransactions = await this.prisma.transaction.findMany({ + where: { buyerId: userId }, + }); + + const sellerTransactions = await this.prisma.transaction.findMany({ + where: { sellerId: userId }, + }); + + const allTransactions = [...buyerTransactions, ...sellerTransactions]; + const pendingTransactions = allTransactions.filter((t) => t.status === 'PENDING').length; + const completedTransactions = allTransactions.filter((t) => t.status === 'COMPLETED').length; + + // Calculate total transaction value + const totalTransactionValue = allTransactions + .filter((t) => t.status === 'COMPLETED') + .reduce((sum, t) => sum.plus(t.amount), new Decimal(0)); + + return { + totalProperties, + activeListings, + pendingTransactions, + completedTransactions, + totalTransactionValue, + }; + } + + private async getRecentActivity(userId: string, limit: number = 10): Promise { + const activities: ActivityItemDto[] = []; + + // Get recent property changes + const recentProperties = await this.prisma.property.findMany({ + where: { ownerId: userId }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }); + + for (const property of recentProperties) { + if (property.createdAt === property.updatedAt) { + // Property was just created + activities.push({ + id: `prop-created-${property.id}`, + type: 'property_created', + title: `Property Listed: ${property.title}`, + description: `You listed a new property at ${property.address}`, + timestamp: property.createdAt, + relatedId: property.id, + }); + } else { + // Property was updated + activities.push({ + id: `prop-updated-${property.id}`, + type: 'property_updated', + title: `Property Updated: ${property.title}`, + description: `You updated property at ${property.address}`, + timestamp: property.updatedAt, + relatedId: property.id, + }); + } + } + + // Get recent transactions + const recentTransactions = await this.prisma.transaction.findMany({ + where: { + OR: [{ buyerId: userId }, { sellerId: userId }], + }, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + property: true, + }, + }); + + for (const transaction of recentTransactions) { + const isBuyer = transaction.buyerId === userId; + const role = isBuyer ? 'bought' : 'sold'; + const type = transaction.status === 'COMPLETED' ? 'transaction_completed' : 'transaction_pending'; + + activities.push({ + id: transaction.id, + type, + title: `Transaction ${type === 'transaction_completed' ? 'Completed' : 'Pending'}: ${transaction.property.title}`, + description: `You ${role} a property for $${transaction.amount.toString()}`, + timestamp: transaction.createdAt, + relatedId: transaction.id, + }); + } + + // Sort by timestamp and limit + return activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, limit); + } + + private async getRecommendations(userId: string, limit: number = 5): Promise { + // Get user's owned properties to understand their market segment + const userProperties = await this.prisma.property.findMany({ + where: { ownerId: userId }, + select: { city: true, state: true, price: true, propertyType: true }, + }); + + if (userProperties.length === 0) { + // If user has no properties, get popular listings + const recommendations = await this.prisma.property.findMany({ + where: { + status: 'ACTIVE', + ownerId: { not: userId }, + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }); + + return recommendations.map((prop) => ({ + id: prop.id, + title: prop.title, + address: prop.address, + city: prop.city, + state: prop.state, + price: prop.price, + propertyType: prop.propertyType, + bedrooms: prop.bedrooms, + bathrooms: prop.bathrooms, + reason: 'Recently listed popular property', + })); + } + + // Get similar properties based on user's portfolio + const similarProperties = await this.prisma.property.findMany({ + where: { + status: 'ACTIVE', + ownerId: { not: userId }, + OR: userProperties.map((prop) => ({ + AND: [ + { city: prop.city }, + { state: prop.state }, + { price: { gte: prop.price.multiply(0.8), lte: prop.price.multiply(1.2) } }, + ], + })), + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }); + + return similarProperties.map((prop) => ({ + id: prop.id, + title: prop.title, + address: prop.address, + city: prop.city, + state: prop.state, + price: prop.price, + propertyType: prop.propertyType, + bedrooms: prop.bedrooms, + bathrooms: prop.bathrooms, + reason: `Similar to properties in ${prop.city}, ${prop.state}`, + })); + } +} diff --git a/src/dashboard/dto/dashboard.dto.ts b/src/dashboard/dto/dashboard.dto.ts new file mode 100644 index 00000000..0d73d2c7 --- /dev/null +++ b/src/dashboard/dto/dashboard.dto.ts @@ -0,0 +1,56 @@ +import { Decimal } from '@prisma/client/runtime/library'; + +// Profile Summary DTO +export class ProfileSummaryDto { + id: string; + firstName: string; + lastName: string; + email: string; + phone?: string; + role: string; + avatar?: string; + isVerified: boolean; + createdAt: Date; + memberSince: string; // formatted date +} + +// Activity Item DTO +export class ActivityItemDto { + id: string; + type: 'property_created' | 'transaction_completed' | 'property_updated' | 'transaction_pending'; + title: string; + description: string; + timestamp: Date; + relatedId?: string; // property or transaction id +} + +// Quick Stats DTO +export class QuickStatsDto { + totalProperties: number; + activeListings: number; + pendingTransactions: number; + completedTransactions: number; + totalTransactionValue: Decimal; +} + +// Recommendation Item DTO +export class RecommendationItemDto { + id: string; + title: string; + address: string; + city: string; + state: string; + price: Decimal; + propertyType: string; + bedrooms?: number; + bathrooms?: Decimal; + reason: string; // why it's recommended +} + +// Main Dashboard DTO +export class DashboardDto { + profile: ProfileSummaryDto; + stats: QuickStatsDto; + recentActivity: ActivityItemDto[]; + recommendations: RecommendationItemDto[]; +} diff --git a/src/sessions/dto/session.dto.ts b/src/sessions/dto/session.dto.ts new file mode 100644 index 00000000..7cb131f8 --- /dev/null +++ b/src/sessions/dto/session.dto.ts @@ -0,0 +1,28 @@ +export class SessionDto { + id: string; + accessTokenJti: string; + refreshTokenJti?: string; + ipAddress?: string; + userAgent?: string; + isRevoked: boolean; + expiresAt: Date; + createdAt: Date; + lastActivityAt: Date; + revokedAt?: Date; +} + +export class SessionsListDto { + sessions: SessionDto[]; + activeCount: number; + revokedCount: number; +} + +export class RevokeSessionDto { + message: string; + sessionId: string; +} + +export class RevokeAllSessionsDto { + message: string; + revokedCount: number; +} diff --git a/src/sessions/sessions.controller.ts b/src/sessions/sessions.controller.ts new file mode 100644 index 00000000..b72afbd6 --- /dev/null +++ b/src/sessions/sessions.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Delete, Get, Param, UseGuards } from '@nestjs/common'; +import { SessionsService } from './sessions.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { SessionsListDto, RevokeSessionDto, RevokeAllSessionsDto } from './dto/session.dto'; + +@Controller('sessions') +@UseGuards(JwtAuthGuard) +export class SessionsController { + constructor(private readonly sessionsService: SessionsService) {} + + /** + * Get all sessions for the current user + */ + @Get() + async getSessions(@CurrentUser() user: AuthUserPayload): Promise { + return this.sessionsService.getUserSessions(user.sub); + } + + /** + * Get details of a specific session + */ + @Get(':sessionId') + async getSession(@Param('sessionId') sessionId: string, @CurrentUser() user: AuthUserPayload) { + return this.sessionsService.getSession(sessionId); + } + + /** + * Revoke a specific session + */ + @Delete(':sessionId') + async revokeSession( + @Param('sessionId') sessionId: string, + @CurrentUser() user: AuthUserPayload, + ): Promise { + return this.sessionsService.revokeSession(user.sub, sessionId); + } + + /** + * Revoke all sessions for the current user (optionally except the current one) + */ + @Delete() + async revokeAllSessions( + @CurrentUser() user: AuthUserPayload, + ): Promise { + // If current session is tracked via JWT JTI, we could pass it to keep it active + // For now, we'll revoke all sessions + return this.sessionsService.revokeAllSessions(user.sub); + } +} diff --git a/src/sessions/sessions.module.ts b/src/sessions/sessions.module.ts new file mode 100644 index 00000000..7f09345f --- /dev/null +++ b/src/sessions/sessions.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SessionsService } from './sessions.service'; +import { SessionsController } from './sessions.controller'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [SessionsController], + providers: [SessionsService], + exports: [SessionsService], +}) +export class SessionsModule {} diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts new file mode 100644 index 00000000..187d90c5 --- /dev/null +++ b/src/sessions/sessions.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { SessionDto, SessionsListDto, RevokeSessionDto, RevokeAllSessionsDto } from './dto/session.dto'; + +@Injectable() +export class SessionsService { + constructor(private prisma: PrismaService) {} + + /** + * Create a new session + */ + async createSession( + userId: string, + accessTokenJti: string, + refreshTokenJti: string, + ipAddress?: string, + userAgent?: string, + expiresInSeconds: number = 7 * 24 * 60 * 60, // 7 days + ): Promise { + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); + + const session = await this.prisma.session.create({ + data: { + userId, + accessTokenJti, + refreshTokenJti, + ipAddress, + userAgent, + expiresAt, + }, + }); + + return this.mapSessionToDto(session); + } + + /** + * Get all sessions for a user + */ + async getUserSessions(userId: string): Promise { + const sessions = await this.prisma.session.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + const activeSessions = sessions.filter((s) => !s.isRevoked && s.expiresAt > new Date()); + const revokedSessions = sessions.filter((s) => s.isRevoked); + + return { + sessions: sessions.map((s) => this.mapSessionToDto(s)), + activeCount: activeSessions.length, + revokedCount: revokedSessions.length, + }; + } + + /** + * Get a specific session + */ + async getSession(sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + return this.mapSessionToDto(session); + } + + /** + * Revoke a specific session + */ + async revokeSession(userId: string, sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + if (session.userId !== userId) { + throw new NotFoundException('Session not found'); + } + + await this.prisma.session.update({ + where: { id: sessionId }, + data: { + isRevoked: true, + revokedAt: new Date(), + }, + }); + + return { + message: 'Session revoked successfully', + sessionId, + }; + } + + /** + * Revoke all sessions for a user (except optionally the current one) + */ + async revokeAllSessions(userId: string, exceptSessionId?: string): Promise { + const where: any = { userId }; + if (exceptSessionId) { + where.id = { not: exceptSessionId }; + } + + const updateResult = await this.prisma.session.updateMany({ + where, + data: { + isRevoked: true, + revokedAt: new Date(), + }, + }); + + return { + message: 'All sessions revoked successfully', + revokedCount: updateResult.count, + }; + } + + /** + * Update session's last activity timestamp + */ + async updateSessionActivity(sessionId: string): Promise { + await this.prisma.session.update({ + where: { id: sessionId }, + data: { + lastActivityAt: new Date(), + }, + }); + } + + /** + * Check if a session is valid and active + */ + async isSessionValid(sessionId: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + return false; + } + + // Session is valid if it's not revoked and hasn't expired + return !session.isRevoked && session.expiresAt > new Date(); + } + + /** + * Get session by access token JTI + */ + async getSessionByAccessTokenJti(accessTokenJti: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { accessTokenJti }, + }); + + if (!session) { + return null; + } + + return this.mapSessionToDto(session); + } + + /** + * Clean up expired sessions (for maintenance) + */ + async cleanupExpiredSessions(): Promise { + const result = await this.prisma.session.deleteMany({ + where: { + expiresAt: { + lt: new Date(), + }, + }, + }); + + return result.count; + } + + /** + * Map Prisma session to DTO + */ + private mapSessionToDto(session: any): SessionDto { + return { + id: session.id, + accessTokenJti: session.accessTokenJti, + refreshTokenJti: session.refreshTokenJti, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + isRevoked: session.isRevoked, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + lastActivityAt: session.lastActivityAt, + revokedAt: session.revokedAt, + }; + } +}