feat: implement privacy-aware profile visibility and activity status filtering using optional authentication guard

This commit is contained in:
pradeepkumar
2026-03-28 18:46:49 +05:30
parent ba931c350b
commit a71145f6a6
8 changed files with 235 additions and 38 deletions

View File

@@ -7,6 +7,7 @@ import {
Body, Body,
Param, Param,
Query, Query,
Req,
UseGuards, UseGuards,
ParseUUIDPipe, ParseUUIDPipe,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -25,6 +26,7 @@ import {
} from './dto'; } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { OptionalAuthGuard } from '../auth/guards/optional-auth.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { CurrentUser } from '../auth/decorators/current-user.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/* // Public parameterized routes - MUST come after specific routes like profile/*
@Get(':id') @Get(':id')
@Public() @Public()
@UseGuards(OptionalAuthGuard)
@ApiOperation({ summary: 'Get agent profile by ID' }) @ApiOperation({ summary: 'Get agent profile by ID' })
@ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' }) @ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' })
@ApiResponse({ status: 404, description: 'Agent profile not found' }) @ApiResponse({ status: 404, description: 'Agent profile not found' })
async getAgentById(@Param('id', ParseUUIDPipe) id: string) { async getAgentById(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
return this.agentsService.getProfileById(id); const requestingUserId = req.user?.id || null;
return this.agentsService.getProfileById(id, requestingUserId);
} }
@Get(':id/field-values') @Get(':id/field-values')

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AgentsController } from './agents.controller'; import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ConnectionRequestsModule } from '../connection-requests/connection-requests.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, ConnectionRequestsModule],
controllers: [AgentsController], controllers: [AgentsController],
providers: [AgentsService], providers: [AgentsService],
exports: [AgentsService], exports: [AgentsService],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
BadRequestException, BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
@@ -12,6 +13,7 @@ import {
SaveFieldValuesDto, SaveFieldValuesDto,
} from './dto'; } from './dto';
import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client'; import { Prisma, Prisma as PrismaTypes, VerificationStatus } from '@prisma/client';
import { ConnectionRequestsService } from '../connection-requests/connection-requests.service';
function generateSlug(firstName: string, lastName: string): string { function generateSlug(firstName: string, lastName: string): string {
const base = `${firstName}-${lastName}` const base = `${firstName}-${lastName}`
@@ -24,7 +26,24 @@ function generateSlug(firstName: string, lastName: string): string {
@Injectable() @Injectable()
export class AgentsService { 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) { async createProfile(userId: string, dto: CreateAgentProfileDto) {
// Check if user already has an agent profile // Check if user already has an agent profile
@@ -81,7 +100,7 @@ export class AgentsService {
return profile; return profile;
} }
async getProfileById(profileId: string) { async getProfileById(profileId: string, requestingUserId?: string) {
const profile = await this.prisma.agentProfile.findUnique({ const profile = await this.prisma.agentProfile.findUnique({
where: { id: profileId }, where: { id: profileId },
include: { include: {
@@ -90,6 +109,7 @@ export class AgentsService {
id: true, id: true,
email: true, email: true,
avatar: true, avatar: true,
privacyPreferences: true,
}, },
}, },
agentType: true, agentType: true,
@@ -100,7 +120,30 @@ export class AgentsService {
throw new NotFoundException('Agent profile not found'); 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) { async updateProfile(userId: string, dto: UpdateAgentProfileDto) {
@@ -197,8 +240,17 @@ export class AgentsService {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Build where clause — only show admin-approved profiles in search // Build where clause — only show admin-approved profiles in search
// Exclude agents who set profile_visibility to 'private'
const where: Prisma.AgentProfileWhereInput = { const where: Prisma.AgentProfileWhereInput = {
verificationStatus: 'APPROVED', verificationStatus: 'APPROVED',
NOT: {
user: {
privacyPreferences: {
path: ['privacySettings', 'profile_visibility'],
equals: 'private',
},
},
},
}; };
if (search) { if (search) {
@@ -484,6 +536,14 @@ export class AgentsService {
isVerified: true, isVerified: true,
isFeatured: true, isFeatured: true,
verificationStatus: 'APPROVED', verificationStatus: 'APPROVED',
NOT: {
user: {
privacyPreferences: {
path: ['privacySettings', 'profile_visibility'],
equals: 'private',
},
},
},
}, },
take: limit, take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }], orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],
@@ -508,6 +568,14 @@ export class AgentsService {
isVerified: true, isVerified: true,
totalReviews: { gte: 5 }, totalReviews: { gte: 5 },
verificationStatus: 'APPROVED', verificationStatus: 'APPROVED',
NOT: {
user: {
privacyPreferences: {
path: ['privacySettings', 'profile_visibility'],
equals: 'private',
},
},
},
}, },
take: limit, take: limit,
orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }], orderBy: [{ averageRating: 'desc' }, { totalReviews: 'desc' }],

View File

@@ -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;
}
}

View File

@@ -17,6 +17,50 @@ export class ConnectionRequestsService {
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
) {} ) {}
/**
* Check if a user is connected to an agent (ACCEPTED connection exists)
*/
async isConnected(userId: string, agentUserId: string): Promise<boolean> {
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<string[]> {
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<string>();
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 * Create a new connection request from user to agent
*/ */

View File

@@ -12,6 +12,8 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MessagesService } from './messages.service'; import { MessagesService } from './messages.service';
import { SupportChatService } from '../support-chat/support-chat.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 { CreateMessageDto } from './dto';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -37,6 +39,8 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect
constructor( constructor(
private readonly messagesService: MessagesService, private readonly messagesService: MessagesService,
private readonly supportChatService: SupportChatService, private readonly supportChatService: SupportChatService,
private readonly connectionRequestsService: ConnectionRequestsService,
private readonly prisma: PrismaService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, 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) { private async broadcastUserStatus(userId: string, isOnline: boolean) {
this.server.emit('user_status_change', { const statusEvent = {
userId, userId,
isOnline, isOnline,
lastSeenAt: isOnline ? null : new Date(), 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);
}
} }
/** /**

View File

@@ -6,11 +6,13 @@ import { MessagesService } from './messages.service';
import { MessagesGateway } from './messages.gateway'; import { MessagesGateway } from './messages.gateway';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { SupportChatModule } from '../support-chat/support-chat.module'; import { SupportChatModule } from '../support-chat/support-chat.module';
import { ConnectionRequestsModule } from '../connection-requests/connection-requests.module';
@Module({ @Module({
imports: [ imports: [
PrismaModule, PrismaModule,
SupportChatModule, SupportChatModule,
ConnectionRequestsModule,
ConfigModule, ConfigModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],

View File

@@ -16,6 +16,17 @@ export class MessagesService {
private readonly eventEmitter: EventEmitter2, 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) * Validate if a user can message an agent (must have ACCEPTED connection)
*/ */
@@ -87,6 +98,7 @@ export class MessagesService {
select: { select: {
isOnline: true, isOnline: true,
lastSeenAt: true, lastSeenAt: true,
privacyPreferences: true,
}, },
}, },
}, },
@@ -96,6 +108,7 @@ export class MessagesService {
id: true, id: true,
isOnline: true, isOnline: true,
lastSeenAt: true, lastSeenAt: true,
privacyPreferences: true,
userProfile: { userProfile: {
select: { select: {
firstName: true, firstName: true,
@@ -130,8 +143,10 @@ export class MessagesService {
} }
// Transform to include otherParty based on caller role // Transform to include otherParty based on caller role
// Conversations only exist between connected users, so isConnected=true
if (callerRole === UserRole.AGENT) { if (callerRole === UserRole.AGENT) {
// Agent is viewing — other party is the user // Agent is viewing — other party is the user
const showStatus = this.shouldShowOnlineStatus(conversation.user.privacyPreferences);
return { return {
...conversation, ...conversation,
unreadCount: conversation.agentUnreadCount, unreadCount: conversation.agentUnreadCount,
@@ -139,12 +154,13 @@ export class MessagesService {
id: conversation.user.id, id: conversation.user.id,
name: `${conversation.user.userProfile?.firstName || ''} ${conversation.user.userProfile?.lastName || ''}`.trim() || 'User', name: `${conversation.user.userProfile?.firstName || ''} ${conversation.user.userProfile?.lastName || ''}`.trim() || 'User',
avatar: conversation.user.userProfile?.avatar, avatar: conversation.user.userProfile?.avatar,
isOnline: conversation.user.isOnline, isOnline: showStatus ? conversation.user.isOnline : false,
lastSeenAt: conversation.user.lastSeenAt, lastSeenAt: showStatus ? conversation.user.lastSeenAt : null,
}, },
}; };
} else { } else {
// User is viewing — other party is the agent // User is viewing — other party is the agent
const showStatus = this.shouldShowOnlineStatus(conversation.agentProfile.user?.privacyPreferences);
return { return {
...conversation, ...conversation,
unreadCount: conversation.userUnreadCount, unreadCount: conversation.userUnreadCount,
@@ -154,8 +170,8 @@ export class MessagesService {
name: `${conversation.agentProfile.firstName || ''} ${conversation.agentProfile.lastName || ''}`.trim() || 'Agent', name: `${conversation.agentProfile.firstName || ''} ${conversation.agentProfile.lastName || ''}`.trim() || 'Agent',
avatar: conversation.agentProfile.avatar, avatar: conversation.agentProfile.avatar,
headline: conversation.agentProfile.headline, headline: conversation.agentProfile.headline,
isOnline: conversation.agentProfile.user?.isOnline || false, isOnline: showStatus ? (conversation.agentProfile.user?.isOnline || false) : false,
lastSeenAt: conversation.agentProfile.user?.lastSeenAt || null, lastSeenAt: showStatus ? (conversation.agentProfile.user?.lastSeenAt || null) : null,
}, },
}; };
} }
@@ -306,6 +322,7 @@ export class MessagesService {
id: true, id: true,
isOnline: true, isOnline: true,
lastSeenAt: true, lastSeenAt: true,
privacyPreferences: true,
userProfile: { userProfile: {
select: { select: {
firstName: true, firstName: true,
@@ -324,17 +341,20 @@ export class MessagesService {
}); });
// Map to include unread count from agent perspective // Map to include unread count from agent perspective
return conversations.map((conv) => ({ return conversations.map((conv) => {
const showStatus = this.shouldShowOnlineStatus(conv.user.privacyPreferences);
return {
...conv, ...conv,
unreadCount: conv.agentUnreadCount, unreadCount: conv.agentUnreadCount,
otherParty: { otherParty: {
id: conv.user.id, id: conv.user.id,
name: `${conv.user.userProfile?.firstName || ''} ${conv.user.userProfile?.lastName || ''}`.trim() || 'User', name: `${conv.user.userProfile?.firstName || ''} ${conv.user.userProfile?.lastName || ''}`.trim() || 'User',
avatar: conv.user.userProfile?.avatar, avatar: conv.user.userProfile?.avatar,
isOnline: conv.user.isOnline, isOnline: showStatus ? conv.user.isOnline : false,
lastSeenAt: conv.user.lastSeenAt, lastSeenAt: showStatus ? conv.user.lastSeenAt : null,
}, },
})); };
});
} else { } else {
// Regular user // Regular user
conversations = await this.prisma.conversation.findMany({ conversations = await this.prisma.conversation.findMany({
@@ -352,6 +372,7 @@ export class MessagesService {
select: { select: {
isOnline: true, isOnline: true,
lastSeenAt: true, lastSeenAt: true,
privacyPreferences: true,
}, },
}, },
}, },
@@ -365,7 +386,9 @@ export class MessagesService {
}); });
// Map to include unread count from user perspective // Map to include unread count from user perspective
return conversations.map((conv) => ({ return conversations.map((conv) => {
const showStatus = this.shouldShowOnlineStatus(conv.agentProfile.user.privacyPreferences);
return {
...conv, ...conv,
unreadCount: conv.userUnreadCount, unreadCount: conv.userUnreadCount,
otherParty: { otherParty: {
@@ -374,10 +397,11 @@ export class MessagesService {
name: `${conv.agentProfile.firstName || ''} ${conv.agentProfile.lastName || ''}`.trim() || 'Agent', name: `${conv.agentProfile.firstName || ''} ${conv.agentProfile.lastName || ''}`.trim() || 'Agent',
avatar: conv.agentProfile.avatar, avatar: conv.agentProfile.avatar,
headline: conv.agentProfile.headline, headline: conv.agentProfile.headline,
isOnline: conv.agentProfile.user.isOnline, isOnline: showStatus ? conv.agentProfile.user.isOnline : false,
lastSeenAt: conv.agentProfile.user.lastSeenAt, lastSeenAt: showStatus ? conv.agentProfile.user.lastSeenAt : null,
}, },
})); };
});
} }
} }