diff --git a/src/agents/agents.controller.ts b/src/agents/agents.controller.ts index fb10cf3..62236f5 100644 --- a/src/agents/agents.controller.ts +++ b/src/agents/agents.controller.ts @@ -7,6 +7,7 @@ import { Body, Param, Query, + Req, UseGuards, ParseUUIDPipe, } from '@nestjs/common'; @@ -25,6 +26,7 @@ import { } from './dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { OptionalAuthGuard } from '../auth/guards/optional-auth.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { Public } from '../auth/decorators/public.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -243,11 +245,13 @@ export class AgentsController { // Public parameterized routes - MUST come after specific routes like profile/* @Get(':id') @Public() + @UseGuards(OptionalAuthGuard) @ApiOperation({ summary: 'Get agent profile by ID' }) @ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' }) @ApiResponse({ status: 404, description: 'Agent profile not found' }) - async getAgentById(@Param('id', ParseUUIDPipe) id: string) { - return this.agentsService.getProfileById(id); + async getAgentById(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { + const requestingUserId = req.user?.id || null; + return this.agentsService.getProfileById(id, requestingUserId); } @Get(':id/field-values') diff --git a/src/agents/agents.module.ts b/src/agents/agents.module.ts index 0a93f5c..3f92c71 100644 --- a/src/agents/agents.module.ts +++ b/src/agents/agents.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AgentsController } from './agents.controller'; import { AgentsService } from './agents.service'; import { PrismaModule } from '../prisma/prisma.module'; +import { ConnectionRequestsModule } from '../connection-requests/connection-requests.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ConnectionRequestsModule], controllers: [AgentsController], providers: [AgentsService], exports: [AgentsService], diff --git a/src/agents/agents.service.ts b/src/agents/agents.service.ts index ebe5a2a..f6a8079 100644 --- a/src/agents/agents.service.ts +++ b/src/agents/agents.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ConflictException, BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -12,6 +13,7 @@ import { SaveFieldValuesDto, } from './dto'; import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client'; +import { ConnectionRequestsService } from '../connection-requests/connection-requests.service'; function generateSlug(firstName: string, lastName: string): string { const base = `${firstName}-${lastName}` @@ -24,7 +26,24 @@ function generateSlug(firstName: string, lastName: string): string { @Injectable() export class AgentsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private connectionRequestsService: ConnectionRequestsService, + ) {} + + /** + * Get profile_visibility setting from user's privacy preferences + */ + private getProfileVisibility(privacyPreferences: any): string { + return privacyPreferences?.privacySettings?.profile_visibility || 'public'; + } + + /** + * Get activity_status setting from user's privacy preferences + */ + private getActivityStatus(privacyPreferences: any): string { + return privacyPreferences?.privacySettings?.activity_status || 'public'; + } async createProfile(userId: string, dto: CreateAgentProfileDto) { // Check if user already has an agent profile @@ -81,7 +100,7 @@ export class AgentsService { return profile; } - async getProfileById(profileId: string) { + async getProfileById(profileId: string, requestingUserId?: string) { const profile = await this.prisma.agentProfile.findUnique({ where: { id: profileId }, include: { @@ -90,6 +109,7 @@ export class AgentsService { id: true, email: true, avatar: true, + privacyPreferences: true, }, }, agentType: true, @@ -100,7 +120,30 @@ export class AgentsService { throw new NotFoundException('Agent profile not found'); } - return profile; + const visibility = this.getProfileVisibility(profile.user.privacyPreferences); + + // Own profile — always visible + if (requestingUserId && requestingUserId === profile.user.id) { + const { privacyPreferences, ...userWithoutPrefs } = profile.user; + return { ...profile, user: userWithoutPrefs }; + } + + if (visibility === 'private') { + throw new ForbiddenException('This profile is not available'); + } + + if (visibility === 'connections' && requestingUserId) { + const connected = await this.connectionRequestsService.isConnected(requestingUserId, profile.user.id); + if (!connected) { + throw new ForbiddenException('This profile is only visible to connections'); + } + } else if (visibility === 'connections' && !requestingUserId) { + throw new ForbiddenException('This profile is only visible to connections'); + } + + // Strip privacyPreferences from response + const { privacyPreferences, ...userWithoutPrefs } = profile.user; + return { ...profile, user: userWithoutPrefs }; } async updateProfile(userId: string, dto: UpdateAgentProfileDto) { @@ -197,8 +240,17 @@ export class AgentsService { const skip = (page - 1) * limit; // Build where clause — only show admin-approved profiles in search + // Exclude agents who set profile_visibility to 'private' const where: Prisma.AgentProfileWhereInput = { verificationStatus: 'APPROVED', + NOT: { + user: { + privacyPreferences: { + path: ['privacySettings', 'profile_visibility'], + equals: 'private', + }, + }, + }, }; if (search) { @@ -484,6 +536,14 @@ export class AgentsService { isVerified: true, isFeatured: true, verificationStatus: 'APPROVED', + NOT: { + user: { + privacyPreferences: { + path: ['privacySettings', 'profile_visibility'], + equals: 'private', + }, + }, + }, }, take: limit, orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }], @@ -508,6 +568,14 @@ export class AgentsService { isVerified: true, totalReviews: { gte: 5 }, verificationStatus: 'APPROVED', + NOT: { + user: { + privacyPreferences: { + path: ['privacySettings', 'profile_visibility'], + equals: 'private', + }, + }, + }, }, take: limit, orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }], diff --git a/src/auth/guards/optional-auth.guard.ts b/src/auth/guards/optional-auth.guard.ts new file mode 100644 index 0000000..2bf2c39 --- /dev/null +++ b/src/auth/guards/optional-auth.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Optional JWT auth guard — extracts user if token is present, but doesn't reject if missing. + * Use on public endpoints that behave differently for authenticated users (e.g., privacy filtering). + */ +@Injectable() +export class OptionalAuthGuard extends AuthGuard('jwt') { + canActivate(context: ExecutionContext) { + return super.canActivate(context); + } + + handleRequest(err: any, user: any) { + // Don't throw on missing/invalid token — just return null + return user || null; + } +} diff --git a/src/connection-requests/connection-requests.service.ts b/src/connection-requests/connection-requests.service.ts index 1add53d..25cde5c 100644 --- a/src/connection-requests/connection-requests.service.ts +++ b/src/connection-requests/connection-requests.service.ts @@ -17,6 +17,50 @@ export class ConnectionRequestsService { private eventEmitter: EventEmitter2, ) {} + /** + * Check if a user is connected to an agent (ACCEPTED connection exists) + */ + async isConnected(userId: string, agentUserId: string): Promise { + const connection = await this.prisma.connectionRequest.findFirst({ + where: { + status: ConnectionStatus.ACCEPTED, + OR: [ + // User → Agent connection + { userId, agentProfile: { userId: agentUserId } }, + // Agent → User connection (if agent has a userId match) + { userId: agentUserId, agentProfile: { userId } }, + ], + }, + }); + return !!connection; + } + + /** + * Get all user IDs connected to a given user (ACCEPTED connections) + */ + async getConnectedUserIds(userId: string): Promise { + const connections = await this.prisma.connectionRequest.findMany({ + where: { + status: ConnectionStatus.ACCEPTED, + OR: [ + { userId }, + { agentProfile: { userId } }, + ], + }, + select: { + userId: true, + agentProfile: { select: { userId: true } }, + }, + }); + + const connectedIds = new Set(); + for (const conn of connections) { + if (conn.userId !== userId) connectedIds.add(conn.userId); + if (conn.agentProfile.userId !== userId) connectedIds.add(conn.agentProfile.userId); + } + return Array.from(connectedIds); + } + /** * Create a new connection request from user to agent */ diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index d5fdf93..96abef5 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -12,6 +12,8 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { MessagesService } from './messages.service'; import { SupportChatService } from '../support-chat/support-chat.service'; +import { ConnectionRequestsService } from '../connection-requests/connection-requests.service'; +import { PrismaService } from '../prisma/prisma.service'; import { CreateMessageDto } from './dto'; import { Logger } from '@nestjs/common'; @@ -37,6 +39,8 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect constructor( private readonly messagesService: MessagesService, private readonly supportChatService: SupportChatService, + private readonly connectionRequestsService: ConnectionRequestsService, + private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) {} @@ -415,14 +419,46 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect } /** - * Broadcast user online/offline status + * Broadcast user online/offline status respecting activity_status privacy setting */ - private broadcastUserStatus(userId: string, isOnline: boolean) { - this.server.emit('user_status_change', { + private async broadcastUserStatus(userId: string, isOnline: boolean) { + const statusEvent = { userId, isOnline, lastSeenAt: isOnline ? null : new Date(), - }); + }; + + try { + // Get user's activity_status privacy setting + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { privacyPreferences: true }, + }); + + const prefs = user?.privacyPreferences as any; + const activityStatus = prefs?.privacySettings?.activity_status || 'public'; + + if (activityStatus === 'private') { + // Don't broadcast status to anyone + return; + } + + if (activityStatus === 'connections') { + // Only send status to connected users + const connectedIds = await this.connectionRequestsService.getConnectedUserIds(userId); + for (const connectedId of connectedIds) { + this.sendToUser(connectedId, 'user_status_change', statusEvent); + } + return; + } + + // 'public' — broadcast to everyone + this.server.emit('user_status_change', statusEvent); + } catch (err) { + // Fallback to public broadcast if privacy check fails + this.logger.warn(`Privacy check failed for ${userId}, broadcasting publicly: ${err.message}`); + this.server.emit('user_status_change', statusEvent); + } } /** diff --git a/src/messages/messages.module.ts b/src/messages/messages.module.ts index 6956b33..dbabddb 100644 --- a/src/messages/messages.module.ts +++ b/src/messages/messages.module.ts @@ -6,11 +6,13 @@ import { MessagesService } from './messages.service'; import { MessagesGateway } from './messages.gateway'; import { PrismaModule } from '../prisma/prisma.module'; import { SupportChatModule } from '../support-chat/support-chat.module'; +import { ConnectionRequestsModule } from '../connection-requests/connection-requests.module'; @Module({ imports: [ PrismaModule, SupportChatModule, + ConnectionRequestsModule, ConfigModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/src/messages/messages.service.ts b/src/messages/messages.service.ts index 74f263c..3516bd8 100644 --- a/src/messages/messages.service.ts +++ b/src/messages/messages.service.ts @@ -16,6 +16,17 @@ export class MessagesService { private readonly eventEmitter: EventEmitter2, ) {} + /** + * Check if a user's activity status allows showing online status. + * Connected users always see online status for 'connections' setting. + */ + private shouldShowOnlineStatus(privacyPreferences: any, isConnected = true): boolean { + const activityStatus = (privacyPreferences as any)?.privacySettings?.activity_status || 'public'; + if (activityStatus === 'public') return true; + if (activityStatus === 'connections' && isConnected) return true; + return false; + } + /** * Validate if a user can message an agent (must have ACCEPTED connection) */ @@ -87,6 +98,7 @@ export class MessagesService { select: { isOnline: true, lastSeenAt: true, + privacyPreferences: true, }, }, }, @@ -96,6 +108,7 @@ export class MessagesService { id: true, isOnline: true, lastSeenAt: true, + privacyPreferences: true, userProfile: { select: { firstName: true, @@ -130,8 +143,10 @@ export class MessagesService { } // Transform to include otherParty based on caller role + // Conversations only exist between connected users, so isConnected=true if (callerRole === UserRole.AGENT) { // Agent is viewing — other party is the user + const showStatus = this.shouldShowOnlineStatus(conversation.user.privacyPreferences); return { ...conversation, unreadCount: conversation.agentUnreadCount, @@ -139,12 +154,13 @@ export class MessagesService { id: conversation.user.id, name: `${conversation.user.userProfile?.firstName || ''} ${conversation.user.userProfile?.lastName || ''}`.trim() || 'User', avatar: conversation.user.userProfile?.avatar, - isOnline: conversation.user.isOnline, - lastSeenAt: conversation.user.lastSeenAt, + isOnline: showStatus ? conversation.user.isOnline : false, + lastSeenAt: showStatus ? conversation.user.lastSeenAt : null, }, }; } else { // User is viewing — other party is the agent + const showStatus = this.shouldShowOnlineStatus(conversation.agentProfile.user?.privacyPreferences); return { ...conversation, unreadCount: conversation.userUnreadCount, @@ -154,8 +170,8 @@ export class MessagesService { name: `${conversation.agentProfile.firstName || ''} ${conversation.agentProfile.lastName || ''}`.trim() || 'Agent', avatar: conversation.agentProfile.avatar, headline: conversation.agentProfile.headline, - isOnline: conversation.agentProfile.user?.isOnline || false, - lastSeenAt: conversation.agentProfile.user?.lastSeenAt || null, + isOnline: showStatus ? (conversation.agentProfile.user?.isOnline || false) : false, + lastSeenAt: showStatus ? (conversation.agentProfile.user?.lastSeenAt || null) : null, }, }; } @@ -306,6 +322,7 @@ export class MessagesService { id: true, isOnline: true, lastSeenAt: true, + privacyPreferences: true, userProfile: { select: { firstName: true, @@ -324,17 +341,20 @@ export class MessagesService { }); // Map to include unread count from agent perspective - return conversations.map((conv) => ({ - ...conv, - unreadCount: conv.agentUnreadCount, - otherParty: { - id: conv.user.id, - name: `${conv.user.userProfile?.firstName || ''} ${conv.user.userProfile?.lastName || ''}`.trim() || 'User', - avatar: conv.user.userProfile?.avatar, - isOnline: conv.user.isOnline, - lastSeenAt: conv.user.lastSeenAt, - }, - })); + return conversations.map((conv) => { + const showStatus = this.shouldShowOnlineStatus(conv.user.privacyPreferences); + return { + ...conv, + unreadCount: conv.agentUnreadCount, + otherParty: { + id: conv.user.id, + name: `${conv.user.userProfile?.firstName || ''} ${conv.user.userProfile?.lastName || ''}`.trim() || 'User', + avatar: conv.user.userProfile?.avatar, + isOnline: showStatus ? conv.user.isOnline : false, + lastSeenAt: showStatus ? conv.user.lastSeenAt : null, + }, + }; + }); } else { // Regular user conversations = await this.prisma.conversation.findMany({ @@ -352,6 +372,7 @@ export class MessagesService { select: { isOnline: true, lastSeenAt: true, + privacyPreferences: true, }, }, }, @@ -365,19 +386,22 @@ export class MessagesService { }); // Map to include unread count from user perspective - return conversations.map((conv) => ({ - ...conv, - unreadCount: conv.userUnreadCount, - otherParty: { - id: conv.agentProfile.id, - userId: conv.agentProfile.userId, - name: `${conv.agentProfile.firstName || ''} ${conv.agentProfile.lastName || ''}`.trim() || 'Agent', - avatar: conv.agentProfile.avatar, - headline: conv.agentProfile.headline, - isOnline: conv.agentProfile.user.isOnline, - lastSeenAt: conv.agentProfile.user.lastSeenAt, - }, - })); + return conversations.map((conv) => { + const showStatus = this.shouldShowOnlineStatus(conv.agentProfile.user.privacyPreferences); + return { + ...conv, + unreadCount: conv.userUnreadCount, + otherParty: { + id: conv.agentProfile.id, + userId: conv.agentProfile.userId, + name: `${conv.agentProfile.firstName || ''} ${conv.agentProfile.lastName || ''}`.trim() || 'Agent', + avatar: conv.agentProfile.avatar, + headline: conv.agentProfile.headline, + isOnline: showStatus ? conv.agentProfile.user.isOnline : false, + lastSeenAt: showStatus ? conv.agentProfile.user.lastSeenAt : null, + }, + }; + }); } }