Commit 31c97f0d authored by Administrator's avatar Administrator

Update 23 files via Son of Anton

parent fcbd2e22
......@@ -33,6 +33,11 @@ import { BountiesModule } from './modules/bounties/bounties.module';
import { AdjustmentsModule } from './modules/adjustments/adjustments.module';
import { PayrollModule } from './modules/payroll/payroll.module';
// ─── Phase 1E: Communication & Notifications ────────────────
import { NotificationsModule } from './modules/notifications/notifications.module';
import { MessagesModule } from './modules/messages/messages.module';
import { NoticesModule } from './modules/notices/notices.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -69,6 +74,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
BountiesModule,
AdjustmentsModule,
PayrollModule,
// Phase 1E
NotificationsModule,
MessagesModule,
NoticesModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import { IsString, IsOptional, IsArray, MinLength } from 'class-validator';
export class CreateConversationDto {
@IsArray()
@IsString({ each: true })
participantIds: string[];
@IsOptional()
@IsString()
@MinLength(1)
name?: string; // For group conversations
@IsOptional()
@IsString()
initialMessage?: string;
}
\ No newline at end of file
export class ConversationResponseDto {
id: string;
type: string;
name: string | null;
participants: Array<{
id: string;
userId: string;
firstName: string;
lastName: string;
avatar: string | null;
unreadCount: number;
lastReadAt: string | null;
}>;
lastMessageAt: string | null;
lastMessageText: string | null;
createdAt: string;
}
export class MessageResponseDto {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string | null;
content: string | null;
type: string;
fileUrl: string | null;
fileName: string | null;
replyToId: string | null;
replyToPreview: any | null;
mentions: string[] | null;
isEdited: boolean;
isPinned: boolean;
createdAt: string;
}
\ No newline at end of file
import { IsString, IsOptional, MinLength } from 'class-validator';
export class SendMessageDto {
@IsOptional()
@IsString()
@MinLength(1)
content?: string;
@IsOptional()
@IsString()
type?: string; // TEXT, FILE, SYSTEM
@IsOptional()
@IsString()
replyToId?: string;
@IsOptional()
mentions?: string[]; // Array of user IDs
// File fields (when type is FILE)
@IsOptional()
@IsString()
fileUrl?: string;
@IsOptional()
@IsString()
fileName?: string;
@IsOptional()
@IsString()
fileMimeType?: string;
@IsOptional()
fileSizeBytes?: number;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { MessagesService } from './messages.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('conversations')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}
@Post()
async createConversation(
@Body() dto: CreateConversationDto,
@CurrentUser() user: RequestUser,
) {
return this.messagesService.createConversation(dto, user);
}
@Get()
async getConversations(
@CurrentUser() user: RequestUser,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.messagesService.getConversations(
user,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get(':id')
async getConversation(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.messagesService.getConversationById(id, user);
}
@Get(':id/messages')
async getMessages(
@Param('id') id: string,
@CurrentUser() user: RequestUser,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.messagesService.getMessages(
id,
user,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
);
}
@Post(':id/messages')
async sendMessage(
@Param('id') id: string,
@Body() dto: SendMessageDto,
@CurrentUser() user: RequestUser,
) {
return this.messagesService.sendMessage(id, dto, user);
}
@Post(':id/participants')
async addParticipant(
@Param('id') id: string,
@Body('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
return this.messagesService.addParticipant(id, userId, user);
}
@Delete(':id/participants/:userId')
@HttpCode(HttpStatus.OK)
async removeParticipant(
@Param('id') id: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
await this.messagesService.removeParticipant(id, userId, user);
return { message: 'Participant removed' };
}
@Delete('messages/:messageId')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deleteMessage(@Param('messageId') messageId: string, @CurrentUser() user: RequestUser) {
await this.messagesService.deleteMessage(messageId, user);
return { message: 'Message deleted' };
}
}
\ No newline at end of file
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
@WebSocketGateway({
namespace: '/messages',
cors: { origin: '*', credentials: true },
})
export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(MessagesGateway.name);
private userSockets = new Map<string, Set<string>>(); // userId -> Set<socketId>
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) {
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'),
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, role: true, status: true },
});
if (!user || user.status === 'OFFBOARDED') {
client.disconnect();
return;
}
(client as any).userId = user.id;
(client as any).userRole = user.role;
// Track socket
if (!this.userSockets.has(user.id)) {
this.userSockets.set(user.id, new Set());
}
this.userSockets.get(user.id)!.add(client.id);
// Auto-join all user's conversation rooms
const participations = await this.prisma.conversationParticipant.findMany({
where: { userId: user.id },
select: { conversationId: true },
});
for (const p of participations) {
client.join(`conversation:${p.conversationId}`);
}
this.logger.log(`Messages client connected: ${user.id}`);
} catch (err) {
this.logger.warn(`Messages connection failed: ${err.message}`);
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
const userId = (client as any).userId;
if (userId) {
const sockets = this.userSockets.get(userId);
if (sockets) {
sockets.delete(client.id);
if (sockets.size === 0) {
this.userSockets.delete(userId);
}
}
this.logger.log(`Messages client disconnected: ${userId}`);
}
}
@SubscribeMessage('message:join_conversation')
handleJoinConversation(
@ConnectedSocket() client: Socket,
@MessageBody() data: { conversationId: string },
): void {
client.join(`conversation:${data.conversationId}`);
}
@SubscribeMessage('message:leave_conversation')
handleLeaveConversation(
@ConnectedSocket() client: Socket,
@MessageBody() data: { conversationId: string },
): void {
client.leave(`conversation:${data.conversationId}`);
}
@SubscribeMessage('message:typing')
handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { conversationId: string },
): void {
const userId = (client as any).userId;
client.to(`conversation:${data.conversationId}`).emit('message:typing', {
conversationId: data.conversationId,
userId,
});
}
@SubscribeMessage('message:stop_typing')
handleStopTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { conversationId: string },
): void {
const userId = (client as any).userId;
client.to(`conversation:${data.conversationId}`).emit('message:stop_typing', {
conversationId: data.conversationId,
userId,
});
}
@SubscribeMessage('message:read')
async handleRead(
@ConnectedSocket() client: Socket,
@MessageBody() data: { conversationId: string },
): Promise<void> {
const userId = (client as any).userId;
await this.prisma.conversationParticipant.update({
where: { conversationId_userId: { conversationId: data.conversationId, userId } },
data: { lastReadAt: new Date(), unreadCount: 0 },
});
this.server.to(`conversation:${data.conversationId}`).emit('message:read', {
conversationId: data.conversationId,
userId,
readAt: new Date().toISOString(),
});
}
// ─── Push Methods ─────────────────────────────────────
sendNewMessage(conversationId: string, message: any): void {
this.server.to(`conversation:${conversationId}`).emit('message:new', {
conversationId,
message,
});
}
sendMessageDeleted(conversationId: string, messageId: string): void {
this.server.to(`conversation:${conversationId}`).emit('message:deleted', {
conversationId,
messageId,
});
}
sendParticipantAdded(conversationId: string, participant: any): void {
this.server.to(`conversation:${conversationId}`).emit('message:participant_added', {
conversationId,
participant,
});
}
sendParticipantRemoved(conversationId: string, userId: string): void {
this.server.to(`conversation:${conversationId}`).emit('message:participant_removed', {
conversationId,
userId,
});
}
isUserOnline(userId: string): boolean {
return this.userSockets.has(userId) && this.userSockets.get(userId)!.size > 0;
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
import { MessagesGateway } from './messages.gateway';
@Module({
imports: [JwtModule, ConfigModule],
controllers: [MessagesController],
providers: [MessagesService, MessagesGateway],
exports: [MessagesService, MessagesGateway],
})
export class MessagesModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class MessagesService {
private readonly logger = new Logger(MessagesService.name);
constructor(private readonly prisma: PrismaService) {}
async createConversation(dto: CreateConversationDto, currentUser: RequestUser): Promise<any> {
if (dto.participantIds.length === 0) {
throw new BadRequestException('At least one participant is required');
}
// Remove duplicates and ensure creator is included
const allParticipantIds = [...new Set([currentUser.id, ...dto.participantIds])];
// Enforce messaging rules
await this.enforceMessagingRules(currentUser, dto.participantIds);
// Verify all participants exist
const users = await this.prisma.user.findMany({
where: { id: { in: allParticipantIds }, deletedAt: null },
select: { id: true },
});
if (users.length !== allParticipantIds.length) {
throw new BadRequestException('One or more participants not found');
}
const type = allParticipantIds.length === 2 ? 'DIRECT' : 'GROUP';
// For DMs, check if conversation already exists
if (type === 'DIRECT') {
const existing = await this.findExistingDM(allParticipantIds[0], allParticipantIds[1]);
if (existing) {
return this.formatConversation(existing, currentUser.id);
}
}
// For groups, only SA/Admin/PL can create
if (type === 'GROUP') {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot create group conversations');
}
}
const conversation = await this.prisma.conversation.create({
data: {
type,
name: type === 'GROUP' ? (dto.name || 'Group Chat') : null,
createdById: currentUser.id,
participants: {
create: allParticipantIds.map((userId) => ({
userId,
})),
},
},
include: {
participants: {
include: {
user: {
select: { id: true, firstName: true, lastName: true, avatar: true, role: true },
},
},
},
},
});
// Send initial message if provided
if (dto.initialMessage) {
await this.sendMessage(conversation.id, {
content: dto.initialMessage,
type: 'TEXT',
}, currentUser);
}
this.logger.log(
`Conversation created: ${type} with ${allParticipantIds.length} participants by ${currentUser.email}`,
);
return this.formatConversation(conversation, currentUser.id);
}
async getConversations(currentUser: RequestUser, page = 1, limit = 20): Promise<PaginatedResult<any>> {
const where: any = {
participants: { some: { userId: currentUser.id } },
};
const [conversations, total] = await Promise.all([
this.prisma.conversation.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
include: {
participants: {
include: {
user: {
select: { id: true, firstName: true, lastName: true, avatar: true, role: true, status: true },
},
},
},
},
}),
this.prisma.conversation.count({ where }),
]);
const formatted = conversations.map((c: any) => this.formatConversation(c, currentUser.id));
return buildPaginatedResponse(formatted, total, { page, limit, sortOrder: 'desc' });
}
async getMessages(
conversationId: string,
currentUser: RequestUser,
page = 1,
limit = 50,
): Promise<PaginatedResult<any>> {
// Verify user is a participant
const participant = await this.prisma.conversationParticipant.findUnique({
where: { conversationId_userId: { conversationId, userId: currentUser.id } },
});
// Super Admin can view any conversation
if (!participant && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You are not a participant in this conversation');
}
const [messages, total] = await Promise.all([
this.prisma.message.findMany({
where: { conversationId, deletedAt: null },
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
sender: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
replyTo: {
select: {
id: true,
content: true,
sender: { select: { id: true, firstName: true, lastName: true } },
},
},
},
}),
this.prisma.message.count({ where: { conversationId, deletedAt: null } }),
]);
// Mark as read
if (participant) {
await this.prisma.conversationParticipant.update({
where: { conversationId_userId: { conversationId, userId: currentUser.id } },
data: { lastReadAt: new Date(), unreadCount: 0 },
});
}
const formatted = messages.map((m: any) => this.formatMessage(m));
return buildPaginatedResponse(formatted, total, { page, limit, sortOrder: 'desc' });
}
async sendMessage(
conversationId: string,
dto: SendMessageDto,
currentUser: RequestUser,
): Promise<any> {
// Verify conversation exists
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
participants: { select: { userId: true } },
},
});
if (!conversation) {
throw new NotFoundException('Conversation not found');
}
// Verify user is a participant
const isParticipant = conversation.participants.some(
(p: any) => p.userId === currentUser.id,
);
if (!isParticipant && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You are not a participant in this conversation');
}
if (!dto.content && dto.type !== 'FILE') {
throw new BadRequestException('Message content is required');
}
// Verify reply target exists in this conversation
if (dto.replyToId) {
const replyTarget = await this.prisma.message.findFirst({
where: { id: dto.replyToId, conversationId, deletedAt: null },
});
if (!replyTarget) {
throw new BadRequestException('Reply target message not found in this conversation');
}
}
const message = await this.prisma.message.create({
data: {
conversationId,
senderId: currentUser.id,
content: dto.content || null,
type: dto.type || 'TEXT',
replyToId: dto.replyToId || null,
mentions: dto.mentions || null,
fileUrl: dto.fileUrl || null,
fileName: dto.fileName || null,
fileMimeType: dto.fileMimeType || null,
fileSizeBytes: dto.fileSizeBytes || null,
},
include: {
sender: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
replyTo: {
select: {
id: true,
content: true,
sender: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
// Update conversation last message
await this.prisma.conversation.update({
where: { id: conversationId },
data: {
lastMessageAt: new Date(),
lastMessageText: dto.content?.substring(0, 100) || (dto.type === 'FILE' ? '📎 File' : ''),
},
});
// Increment unread count for all OTHER participants
await this.prisma.conversationParticipant.updateMany({
where: {
conversationId,
userId: { not: currentUser.id },
},
data: {
unreadCount: { increment: 1 },
},
});
return this.formatMessage(message);
}
async deleteMessage(messageId: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete messages');
}
const message = await this.prisma.message.findUnique({ where: { id: messageId } });
if (!message) {
throw new NotFoundException('Message not found');
}
await this.prisma.message.update({
where: { id: messageId },
data: { deletedAt: new Date() },
});
this.logger.log(`Message ${messageId} deleted by ${currentUser.email}`);
}
async addParticipant(conversationId: string, userId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot add participants');
}
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});
if (!conversation) throw new NotFoundException('Conversation not found');
if (conversation.type !== 'GROUP') {
throw new BadRequestException('Can only add participants to group conversations');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null },
});
if (!user) throw new NotFoundException('User not found');
const existing = await this.prisma.conversationParticipant.findUnique({
where: { conversationId_userId: { conversationId, userId } },
});
if (existing) throw new BadRequestException('User is already a participant');
const participant = await this.prisma.conversationParticipant.create({
data: { conversationId, userId },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
// Add system message
await this.prisma.message.create({
data: {
conversationId,
senderId: currentUser.id,
type: 'SYSTEM',
content: `${user.firstName} ${user.lastName} was added to the conversation`,
},
});
return participant;
}
async removeParticipant(conversationId: string, userId: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot remove participants');
}
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
});
if (!conversation) throw new NotFoundException('Conversation not found');
if (conversation.type !== 'GROUP') {
throw new BadRequestException('Can only remove participants from group conversations');
}
const participant = await this.prisma.conversationParticipant.findUnique({
where: { conversationId_userId: { conversationId, userId } },
});
if (!participant) throw new NotFoundException('User is not a participant');
await this.prisma.conversationParticipant.delete({
where: { conversationId_userId: { conversationId, userId } },
});
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { firstName: true, lastName: true },
});
// Add system message
await this.prisma.message.create({
data: {
conversationId,
senderId: currentUser.id,
type: 'SYSTEM',
content: `${user?.firstName} ${user?.lastName} was removed from the conversation`,
},
});
}
async getConversationById(conversationId: string, currentUser: RequestUser): Promise<any> {
const conversation = await this.prisma.conversation.findUnique({
where: { id: conversationId },
include: {
participants: {
include: {
user: {
select: { id: true, firstName: true, lastName: true, avatar: true, role: true, status: true },
},
},
},
},
});
if (!conversation) throw new NotFoundException('Conversation not found');
const isParticipant = conversation.participants.some(
(p: any) => p.userId === currentUser.id,
);
if (!isParticipant && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You are not a participant in this conversation');
}
return this.formatConversation(conversation, currentUser.id);
}
// ─── HELPERS ─────────────────────────────────────────
private async enforceMessagingRules(sender: RequestUser, recipientIds: string[]): Promise<void> {
if (sender.role === 'SUPER_ADMIN' || sender.role === 'ADMIN') {
return; // SA and Admin can message anyone
}
const recipients = await this.prisma.user.findMany({
where: { id: { in: recipientIds }, deletedAt: null },
select: { id: true, role: true, assignedProjectLeaderId: true },
});
for (const recipient of recipients) {
if (sender.role === 'CONTRACTOR') {
// Contractors can only message: SA, Admin, their PL
if (recipient.role === 'CONTRACTOR') {
throw new ForbiddenException(
'Contractors cannot message other contractors. Use card comments for collaboration.',
);
}
if (recipient.role === 'TEAM_LEAD') {
// Check if this TL is assigned to the contractor
const contractor = await this.prisma.user.findUnique({
where: { id: sender.id },
select: { assignedProjectLeaderId: true },
});
if (contractor?.assignedProjectLeaderId !== recipient.id) {
throw new ForbiddenException(
'You can only message your assigned Project Leader',
);
}
}
}
if (sender.role === 'TEAM_LEAD') {
// PLs can message: SA, Admin, contractors on their boards
if (recipient.role === 'CONTRACTOR') {
// Check if the contractor is on one of the PL's boards
const sharedBoards = await this.prisma.boardMember.findMany({
where: {
userId: { in: [sender.id, recipient.id] },
},
select: { boardId: true, userId: true },
});
const senderBoards = new Set(
sharedBoards.filter((b: any) => b.userId === sender.id).map((b: any) => b.boardId),
);
const recipientBoards = sharedBoards.filter((b: any) => b.userId === recipient.id).map((b: any) => b.boardId);
const hasSharedBoard = recipientBoards.some((boardId: string) => senderBoards.has(boardId));
if (!hasSharedBoard) {
throw new ForbiddenException(
'You can only message contractors who are on your boards',
);
}
}
}
}
}
private async findExistingDM(userId1: string, userId2: string): Promise<any> {
// Find a DIRECT conversation where both users are participants
const conversations = await this.prisma.conversation.findMany({
where: {
type: 'DIRECT',
AND: [
{ participants: { some: { userId: userId1 } } },
{ participants: { some: { userId: userId2 } } },
],
},
include: {
participants: {
include: {
user: {
select: { id: true, firstName: true, lastName: true, avatar: true, role: true },
},
},
},
},
});
// Filter for exactly 2 participants
return conversations.find((c: any) => c.participants.length === 2) || null;
}
private formatConversation(conversation: any, viewerId: string): any {
const myParticipant = conversation.participants?.find(
(p: any) => p.userId === viewerId,
);
return {
id: conversation.id,
type: conversation.type,
name: conversation.type === 'GROUP'
? conversation.name
: conversation.participants
?.filter((p: any) => p.userId !== viewerId)
.map((p: any) => `${p.user.firstName} ${p.user.lastName}`)
.join(', ') || 'Unknown',
participants: conversation.participants?.map((p: any) => ({
id: p.id,
userId: p.userId,
firstName: p.user.firstName,
lastName: p.user.lastName,
avatar: p.user.avatar,
role: p.user.role,
unreadCount: p.unreadCount,
lastReadAt: p.lastReadAt,
})) || [],
lastMessageAt: conversation.lastMessageAt,
lastMessageText: conversation.lastMessageText,
unreadCount: myParticipant?.unreadCount || 0,
createdAt: conversation.createdAt,
};
}
private formatMessage(message: any): any {
return {
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
senderName: message.sender
? `${message.sender.firstName} ${message.sender.lastName}`
: 'Unknown',
senderAvatar: message.sender?.avatar || null,
content: message.content,
type: message.type,
fileUrl: message.fileUrl,
fileName: message.fileName,
replyToId: message.replyToId,
replyToPreview: message.replyTo
? {
id: message.replyTo.id,
content: message.replyTo.content?.substring(0, 100),
senderName: message.replyTo.sender
? `${message.replyTo.sender.firstName} ${message.replyTo.sender.lastName}`
: 'Unknown',
}
: null,
mentions: message.mentions,
isEdited: message.isEdited,
isPinned: message.isPinned,
createdAt: message.createdAt,
};
}
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, IsArray, MinLength, MaxLength } from 'class-validator';
export class CreateNoticeDto {
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@IsString()
@MinLength(1)
content: string;
@IsString()
type: string; // GENERAL_ANNOUNCEMENT, OFFICIAL_WARNING, POLICY_UPDATE, CUSTOM
@IsOptional()
@IsString()
priority?: string; // NORMAL, HIGH
@IsOptional()
@IsBoolean()
isBlocking?: boolean;
@IsString()
recipientType: string; // ALL_USERS, ALL_CONTRACTORS, SPECIFIC_USERS, BY_BOARD, BY_ROLE
@IsOptional()
@IsArray()
@IsString({ each: true })
recipientIds?: string[];
@IsOptional()
@IsString()
expiresAt?: string;
}
\ No newline at end of file
export class NoticeResponseDto {
id: string;
title: string;
content: string;
type: string;
priority: string;
isBlocking: boolean;
recipientType: string;
createdBy: {
id: string;
firstName: string;
lastName: string;
};
publishedAt: string | null;
expiresAt: string | null;
acknowledgmentCount: number;
totalRecipients: number;
createdAt: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength } from 'class-validator';
export class UpdateNoticeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@MinLength(1)
content?: string;
@IsOptional()
@IsString()
priority?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { NoticesService } from './notices.service';
import { CreateNoticeDto } from './dto/create-notice.dto';
import { UpdateNoticeDto } from './dto/update-notice.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('notices')
export class NoticesController {
constructor(private readonly noticesService: NoticesService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreateNoticeDto, @CurrentUser() user: RequestUser) {
return this.noticesService.create(dto, user);
}
@Get()
async findAll(
@CurrentUser() user: RequestUser,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.noticesService.findAll(
user,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.noticesService.findById(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
async update(
@Param('id') id: string,
@Body() dto: UpdateNoticeDto,
@CurrentUser() user: RequestUser,
) {
return this.noticesService.update(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.noticesService.delete(id, user);
return { message: 'Notice deleted' };
}
@Post(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.noticesService.acknowledge(id, user);
}
@Get(':id/acknowledgments')
@Roles('SUPER_ADMIN', 'ADMIN')
async getAcknowledgmentStatus(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.noticesService.getAcknowledgmentStatus(id, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { NoticesController } from './notices.controller';
import { NoticesService } from './notices.service';
@Module({
controllers: [NoticesController],
providers: [NoticesService],
exports: [NoticesService],
})
export class NoticesModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateNoticeDto } from './dto/create-notice.dto';
import { UpdateNoticeDto } from './dto/update-notice.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class NoticesService {
private readonly logger = new Logger(NoticesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateNoticeDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create notices');
}
const validTypes = ['GENERAL_ANNOUNCEMENT', 'OFFICIAL_WARNING', 'POLICY_UPDATE', 'CUSTOM'];
if (!validTypes.includes(dto.type)) {
throw new BadRequestException(`Type must be one of: ${validTypes.join(', ')}`);
}
const validRecipientTypes = ['ALL_USERS', 'ALL_CONTRACTORS', 'SPECIFIC_USERS', 'BY_BOARD', 'BY_ROLE'];
if (!validRecipientTypes.includes(dto.recipientType)) {
throw new BadRequestException(`Recipient type must be one of: ${validRecipientTypes.join(', ')}`);
}
// Force blocking for certain types
let isBlocking = dto.isBlocking ?? false;
if (dto.type === 'OFFICIAL_WARNING' || dto.type === 'POLICY_UPDATE') {
isBlocking = true;
}
const notice = await this.prisma.notice.create({
data: {
title: dto.title,
content: dto.content,
type: dto.type,
priority: dto.priority || 'NORMAL',
isBlocking,
recipientType: dto.recipientType,
recipientIds: dto.recipientIds || null,
createdById: currentUser.id,
publishedAt: new Date(),
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
},
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
// Send notifications to recipients
const recipientUserIds = await this.resolveRecipients(dto);
for (const userId of recipientUserIds) {
try {
await this.notificationsService.create({
userId,
type: isBlocking ? 'BLOCKING' : (dto.priority === 'HIGH' ? 'IMPORTANT' : 'INFORMATIONAL'),
category: 'NOTICE',
title: dto.title,
message: dto.content.substring(0, 200),
actionUrl: `/notifications`,
isBlocking,
entityType: 'notice',
entityId: notice.id,
triggeredById: currentUser.id,
});
} catch (err) {
this.logger.warn(`Failed to notify user ${userId} about notice: ${err.message}`);
}
}
this.logger.log(
`Notice "${dto.title}" (${dto.type}) created by ${currentUser.email}, sent to ${recipientUserIds.length} users`,
);
return {
...notice,
acknowledgmentCount: 0,
totalRecipients: recipientUserIds.length,
};
}
async findAll(currentUser: RequestUser, page = 1, limit = 20): Promise<PaginatedResult<any>> {
const where: any = {};
// Contractors only see notices targeted at them
if (currentUser.role === 'CONTRACTOR') {
where.OR = [
{ recipientType: 'ALL_USERS' },
{ recipientType: 'ALL_CONTRACTORS' },
{ recipientType: 'SPECIFIC_USERS', recipientIds: { array_contains: [currentUser.id] } },
{ recipientType: 'BY_ROLE', recipientIds: { array_contains: ['CONTRACTOR'] } },
];
}
const [notices, total] = await Promise.all([
this.prisma.notice.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { acknowledgments: true } },
},
}),
this.prisma.notice.count({ where }),
]);
const enriched = notices.map((n: any) => ({
id: n.id,
title: n.title,
content: n.content,
type: n.type,
priority: n.priority,
isBlocking: n.isBlocking,
recipientType: n.recipientType,
createdBy: n.createdBy,
publishedAt: n.publishedAt,
expiresAt: n.expiresAt,
acknowledgmentCount: n._count.acknowledgments,
createdAt: n.createdAt,
}));
return buildPaginatedResponse(enriched, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const notice = await this.prisma.notice.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
acknowledgments: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { acknowledgedAt: 'desc' },
},
_count: { select: { acknowledgments: true } },
},
});
if (!notice) throw new NotFoundException('Notice not found');
// Check if current user has acknowledged
const myAcknowledgment = notice.acknowledgments.find(
(a: any) => a.userId === currentUser.id,
);
return {
...notice,
acknowledgmentCount: notice._count.acknowledgments,
isAcknowledged: !!myAcknowledgment,
acknowledgedAt: myAcknowledgment?.acknowledgedAt || null,
// Only show acknowledgment list to admin+
acknowledgments: currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN'
? notice.acknowledgments
: undefined,
};
}
async update(id: string, dto: UpdateNoticeDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can update notices');
}
const notice = await this.prisma.notice.findUnique({ where: { id } });
if (!notice) throw new NotFoundException('Notice not found');
// Admin can only edit their own
if (currentUser.role === 'ADMIN' && notice.createdById !== currentUser.id) {
throw new ForbiddenException('Admins can only edit their own notices');
}
const updateData: any = {};
if (dto.title !== undefined) updateData.title = dto.title;
if (dto.content !== undefined) updateData.content = dto.content;
if (dto.priority !== undefined) updateData.priority = dto.priority;
return this.prisma.notice.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
const notice = await this.prisma.notice.findUnique({ where: { id } });
if (!notice) throw new NotFoundException('Notice not found');
// Admin can only delete their own
if (currentUser.role === 'ADMIN' && notice.createdById !== currentUser.id) {
throw new ForbiddenException('Admins can only delete their own notices');
}
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can delete notices');
}
await this.prisma.noticeAcknowledgment.deleteMany({ where: { noticeId: id } });
await this.prisma.notice.delete({ where: { id } });
this.logger.log(`Notice ${id} deleted by ${currentUser.email}`);
}
async acknowledge(id: string, currentUser: RequestUser): Promise<any> {
const notice = await this.prisma.notice.findUnique({ where: { id } });
if (!notice) throw new NotFoundException('Notice not found');
const existing = await this.prisma.noticeAcknowledgment.findUnique({
where: { noticeId_userId: { noticeId: id, userId: currentUser.id } },
});
if (existing) {
return existing;
}
const acknowledgment = await this.prisma.noticeAcknowledgment.create({
data: {
noticeId: id,
userId: currentUser.id,
},
});
this.logger.log(`Notice ${id} acknowledged by ${currentUser.id}`);
return acknowledgment;
}
async getAcknowledgmentStatus(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view acknowledgment status');
}
const notice = await this.prisma.notice.findUnique({ where: { id } });
if (!notice) throw new NotFoundException('Notice not found');
const recipientIds = await this.resolveRecipients({
recipientType: notice.recipientType,
recipientIds: notice.recipientIds as string[],
} as any);
const acknowledgments = await this.prisma.noticeAcknowledgment.findMany({
where: { noticeId: id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
const acknowledgedIds = new Set(acknowledgments.map((a: any) => a.userId));
const users = await this.prisma.user.findMany({
where: { id: { in: recipientIds }, deletedAt: null },
select: { id: true, firstName: true, lastName: true, avatar: true },
});
return {
totalRecipients: recipientIds.length,
acknowledged: acknowledgments.length,
pending: recipientIds.length - acknowledgments.length,
acknowledgedUsers: acknowledgments.map((a: any) => ({
user: a.user,
acknowledgedAt: a.acknowledgedAt,
})),
pendingUsers: users.filter((u: any) => !acknowledgedIds.has(u.id)),
};
}
private async resolveRecipients(dto: { recipientType: string; recipientIds?: string[] }): Promise<string[]> {
switch (dto.recipientType) {
case 'ALL_USERS': {
const users = await this.prisma.user.findMany({
where: { status: { notIn: ['OFFBOARDED'] }, deletedAt: null },
select: { id: true },
});
return users.map((u) => u.id);
}
case 'ALL_CONTRACTORS': {
const contractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP', 'SUSPENDED', 'ONBOARDING'] },
deletedAt: null,
},
select: { id: true },
});
return contractors.map((c) => c.id);
}
case 'SPECIFIC_USERS': {
return dto.recipientIds || [];
}
case 'BY_BOARD': {
if (!dto.recipientIds || dto.recipientIds.length === 0) return [];
const members = await this.prisma.boardMember.findMany({
where: { boardId: { in: dto.recipientIds } },
select: { userId: true },
});
return [...new Set(members.map((m) => m.userId))];
}
case 'BY_ROLE': {
if (!dto.recipientIds || dto.recipientIds.length === 0) return [];
const users = await this.prisma.user.findMany({
where: {
role: { in: dto.recipientIds },
status: { notIn: ['OFFBOARDED'] },
deletedAt: null,
},
select: { id: true },
});
return users.map((u) => u.id);
}
default:
return [];
}
}
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
export class CreateNotificationDto {
@IsString()
userId: string;
@IsString()
type: string; // BLOCKING, IMPORTANT, INFORMATIONAL
@IsString()
category: string;
@IsString()
title: string;
@IsString()
message: string;
@IsOptional()
@IsString()
actionUrl?: string;
@IsOptional()
metadata?: any;
@IsOptional()
@IsBoolean()
isBlocking?: boolean;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
entityId?: string;
@IsOptional()
@IsString()
triggeredById?: string;
}
export class CreateBulkNotificationDto {
@IsArray()
@IsString({ each: true })
userIds: string[];
@IsString()
type: string;
@IsString()
category: string;
@IsString()
title: string;
@IsString()
message: string;
@IsOptional()
@IsString()
actionUrl?: string;
@IsOptional()
metadata?: any;
@IsOptional()
@IsBoolean()
isBlocking?: boolean;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
entityId?: string;
@IsOptional()
@IsString()
triggeredById?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class NotificationFilterDto extends PaginationDto {
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isRead?: boolean;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isBlocking?: boolean;
}
\ No newline at end of file
export class NotificationResponseDto {
id: string;
type: string;
category: string;
title: string;
message: string;
actionUrl: string | null;
metadata: any;
isRead: boolean;
readAt: string | null;
acknowledgedAt: string | null;
isBlocking: boolean;
entityType: string | null;
entityId: string | null;
createdAt: string;
}
export class NotificationCountDto {
total: number;
unread: number;
blocking: number;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Query,
Param,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationFilterDto } from './dto/notification-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
async findAll(@Query() filter: NotificationFilterDto, @CurrentUser() user: RequestUser) {
return this.notificationsService.findAll(filter, user);
}
@Get('counts')
async getCounts(@CurrentUser() user: RequestUser) {
return this.notificationsService.getCounts(user.id);
}
@Get('blocking')
async getBlocking(@CurrentUser() user: RequestUser) {
return this.notificationsService.getUnacknowledgedBlocking(user.id);
}
@Put(':id/read')
@HttpCode(HttpStatus.OK)
async markAsRead(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.notificationsService.markAsRead(id, user);
}
@Put(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.notificationsService.acknowledge(id, user);
}
@Put('read-all')
@HttpCode(HttpStatus.OK)
async markAllAsRead(@CurrentUser() user: RequestUser) {
return this.notificationsService.markAllAsRead(user);
}
@Post('send')
@Roles('SUPER_ADMIN', 'ADMIN')
async sendNotification(@Body() body: any, @CurrentUser() user: RequestUser) {
return this.notificationsService.create({
...body,
triggeredById: user.id,
});
}
@Post('send-bulk')
@Roles('SUPER_ADMIN', 'ADMIN')
async sendBulkNotification(@Body() body: any, @CurrentUser() user: RequestUser) {
const count = await this.notificationsService.createBulk({
...body,
triggeredById: user.id,
});
return { sent: count };
}
}
\ No newline at end of file
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
@WebSocketGateway({
namespace: '/notifications',
cors: { origin: '*', credentials: true },
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(NotificationsGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) {
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'),
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, role: true, status: true },
});
if (!user || user.status === 'OFFBOARDED') {
client.disconnect();
return;
}
(client as any).userId = user.id;
(client as any).userRole = user.role;
// Auto-join personal notification room
client.join(`notifications:${user.id}`);
this.logger.log(`Notification client connected: ${user.id}`);
// Push any unacknowledged blocking notifications immediately
const blocking = await this.prisma.notification.findMany({
where: { userId: user.id, isBlocking: true, acknowledgedAt: null },
orderBy: { createdAt: 'asc' },
});
if (blocking.length > 0) {
client.emit('notification:blocking_queue', blocking);
}
// Push unread count
const unreadCount = await this.prisma.notification.count({
where: { userId: user.id, isRead: false },
});
const blockingCount = blocking.length;
client.emit('notification:count_update', {
unread: unreadCount,
blocking: blockingCount,
});
} catch (err) {
this.logger.warn(`Notification connection failed: ${err.message}`);
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
const userId = (client as any).userId;
if (userId) {
this.logger.log(`Notification client disconnected: ${userId}`);
}
}
@SubscribeMessage('notification:mark_read')
async handleMarkRead(
@ConnectedSocket() client: Socket,
): Promise<void> {
// Count update is pushed via sendCountUpdate after service calls
}
// ─── Push Methods (called by NotificationsService) ────────
sendNotification(userId: string, notification: any): void {
this.server.to(`notifications:${userId}`).emit('notification:new', notification);
}
sendBlockingNotification(userId: string, notification: any): void {
this.server.to(`notifications:${userId}`).emit('notification:blocking', notification);
}
sendCountUpdate(userId: string, counts: { unread: number; blocking: number }): void {
this.server.to(`notifications:${userId}`).emit('notification:count_update', counts);
}
}
\ No newline at end of file
import { Module, Global } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationsGateway } from './notifications.gateway';
@Global()
@Module({
imports: [JwtModule, ConfigModule],
controllers: [NotificationsController],
providers: [NotificationsService, NotificationsGateway],
exports: [NotificationsService, NotificationsGateway],
})
export class NotificationsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateNotificationDto, CreateBulkNotificationDto } from './dto/create-notification.dto';
import { NotificationFilterDto } from './dto/notification-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateNotificationDto): Promise<any> {
const validTypes = ['BLOCKING', 'IMPORTANT', 'INFORMATIONAL'];
if (!validTypes.includes(dto.type)) {
throw new BadRequestException(`Type must be one of: ${validTypes.join(', ')}`);
}
const user = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!user) {
this.logger.warn(`Cannot create notification for non-existent user ${dto.userId}`);
return null;
}
const isBlocking = dto.isBlocking ?? dto.type === 'BLOCKING';
const notification = await this.prisma.notification.create({
data: {
userId: dto.userId,
type: dto.type,
category: dto.category,
title: dto.title,
message: dto.message,
actionUrl: dto.actionUrl || null,
metadata: dto.metadata || null,
isBlocking,
entityType: dto.entityType || null,
entityId: dto.entityId || null,
triggeredById: dto.triggeredById || null,
},
});
this.logger.log(
`Notification created: [${dto.type}] "${dto.title}" for user ${dto.userId}`,
);
return notification;
}
async createBulk(dto: CreateBulkNotificationDto): Promise<number> {
let count = 0;
for (const userId of dto.userIds) {
try {
await this.create({
userId,
type: dto.type,
category: dto.category,
title: dto.title,
message: dto.message,
actionUrl: dto.actionUrl,
metadata: dto.metadata,
isBlocking: dto.isBlocking,
entityType: dto.entityType,
entityId: dto.entityId,
triggeredById: dto.triggeredById,
});
count++;
} catch (err) {
this.logger.warn(`Failed to create notification for user ${userId}: ${err.message}`);
}
}
return count;
}
async createForAllActiveContractors(
dto: Omit<CreateNotificationDto, 'userId'>,
): Promise<number> {
const contractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP', 'SUSPENDED'] },
deletedAt: null,
},
select: { id: true },
});
return this.createBulk({
userIds: contractors.map((c) => c.id),
...dto,
} as CreateBulkNotificationDto);
}
async createForAllUsers(
dto: Omit<CreateNotificationDto, 'userId'>,
): Promise<number> {
const users = await this.prisma.user.findMany({
where: {
status: { notIn: ['OFFBOARDED'] },
deletedAt: null,
},
select: { id: true },
});
return this.createBulk({
userIds: users.map((u) => u.id),
...dto,
} as CreateBulkNotificationDto);
}
async findAll(filter: NotificationFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = { userId: currentUser.id };
if (filter.type) where.type = filter.type;
if (filter.category) where.category = filter.category;
if (filter.isRead !== undefined) where.isRead = filter.isRead;
if (filter.isBlocking !== undefined) where.isBlocking = filter.isBlocking;
if (filter.search) {
where.OR = [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ message: { contains: filter.search, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
this.prisma.notification.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.notification.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
async getUnacknowledgedBlocking(userId: string): Promise<any[]> {
return this.prisma.notification.findMany({
where: {
userId,
isBlocking: true,
acknowledgedAt: null,
},
orderBy: { createdAt: 'asc' },
});
}
async getCounts(userId: string): Promise<{ total: number; unread: number; blocking: number }> {
const [total, unread, blocking] = await Promise.all([
this.prisma.notification.count({ where: { userId } }),
this.prisma.notification.count({ where: { userId, isRead: false } }),
this.prisma.notification.count({
where: { userId, isBlocking: true, acknowledgedAt: null },
}),
]);
return { total, unread, blocking };
}
async markAsRead(id: string, currentUser: RequestUser): Promise<any> {
const notification = await this.prisma.notification.findUnique({ where: { id } });
if (!notification) throw new NotFoundException('Notification not found');
if (notification.userId !== currentUser.id) {
throw new ForbiddenException('You can only mark your own notifications as read');
}
if (notification.isRead) return notification;
return this.prisma.notification.update({
where: { id },
data: { isRead: true, readAt: new Date() },
});
}
async acknowledge(id: string, currentUser: RequestUser): Promise<any> {
const notification = await this.prisma.notification.findUnique({ where: { id } });
if (!notification) throw new NotFoundException('Notification not found');
if (notification.userId !== currentUser.id) {
throw new ForbiddenException('You can only acknowledge your own notifications');
}
if (!notification.isBlocking) {
throw new BadRequestException('Only blocking notifications require acknowledgment');
}
if (notification.acknowledgedAt) {
return notification;
}
const updated = await this.prisma.notification.update({
where: { id },
data: {
acknowledgedAt: new Date(),
isRead: true,
readAt: notification.readAt || new Date(),
},
});
this.logger.log(`Blocking notification ${id} acknowledged by ${currentUser.id}`);
return updated;
}
async markAllAsRead(currentUser: RequestUser): Promise<{ count: number }> {
const result = await this.prisma.notification.updateMany({
where: {
userId: currentUser.id,
isRead: false,
isBlocking: false, // Don't auto-read blocking notifications
},
data: { isRead: true, readAt: new Date() },
});
return { count: result.count };
}
async deleteOld(daysOld: number): Promise<number> {
const cutoff = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000);
const result = await this.prisma.notification.deleteMany({
where: {
createdAt: { lt: cutoff },
isBlocking: false,
isRead: true,
},
});
this.logger.log(`Cleaned up ${result.count} old notifications older than ${daysOld} days`);
return result.count;
}
}
\ No newline at end of file
// ═══════════════════════════════════════════════════
// PHASE 1E: COMMUNICATIONS & NOTIFICATIONS
// ═══════════════════════════════════════════════════
// ─── NOTIFICATIONS ─────────────────────────────────
model Notification {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
type String // BLOCKING, IMPORTANT, INFORMATIONAL
category String // CARD, BOARD, SALARY, DEDUCTION, BOUNTY, ADJUSTMENT, PAYROLL, REPORT, EVALUATION, PIP, MESSAGE, MEETING, NOTICE, POLICY, ONBOARDING, UNAVAILABILITY, SCHEDULE, SYSTEM
title String
message String
actionUrl String?
metadata Json?
isRead Boolean @default(false)
readAt DateTime?
acknowledgedAt DateTime?
// For blocking notifications
isBlocking Boolean @default(false)
// Source tracking
entityType String?
entityId String?
triggeredById String?
@@index([userId, isRead])
@@index([userId, isBlocking, acknowledgedAt])
@@index([userId, createdAt])
@@index([type])
@@index([category])
}
// ─── CONVERSATIONS & MESSAGES ──────────────────────
model Conversation {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type String // DIRECT, GROUP
name String? // For group conversations
avatar String? // For group conversations
createdById String
createdBy User @relation("ConversationCreator", fields: [createdById], references: [id], onDelete: Restrict)
participants ConversationParticipant[]
messages Message[]
lastMessageAt DateTime?
lastMessageText String?
@@index([createdById])
@@index([lastMessageAt])
}
model ConversationParticipant {
id String @id @default(uuid())
createdAt DateTime @default(now())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
userId String
user User @relation("ConversationParticipants", fields: [userId], references: [id], onDelete: Cascade)
lastReadAt DateTime?
unreadCount Int @default(0)
isMuted Boolean @default(false)
@@unique([conversationId, userId])
@@index([userId])
@@index([conversationId])
}
model Message {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
senderId String
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Restrict)
content String?
type String @default("TEXT") // TEXT, FILE, SYSTEM
// File attachment
fileUrl String?
fileName String?
fileMimeType String?
fileSizeBytes Int?
// Reply threading
replyToId String?
replyTo Message? @relation("MessageReplies", fields: [replyToId], references: [id], onDelete: SetNull)
replies Message[] @relation("MessageReplies")
// Mentions
mentions Json? // Array of user IDs mentioned
isEdited Boolean @default(false)
isPinned Boolean @default(false)
deletedAt DateTime?
@@index([conversationId, createdAt])
@@index([senderId])
}
// ─── NOTICES & ANNOUNCEMENTS ───────────────────────
model Notice {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
content String // Rich text
type String // GENERAL_ANNOUNCEMENT, OFFICIAL_WARNING, POLICY_UPDATE, CUSTOM
priority String @default("NORMAL") // NORMAL, HIGH
isBlocking Boolean @default(false)
// Recipients targeting
recipientType String // ALL_USERS, ALL_CONTRACTORS, SPECIFIC_USERS, BY_BOARD, BY_ROLE
recipientIds Json? // Array of user IDs, board IDs, or role strings depending on recipientType
createdById String
createdBy User @relation("NoticeCreator", fields: [createdById], references: [id], onDelete: Restrict)
publishedAt DateTime?
expiresAt DateTime?
acknowledgments NoticeAcknowledgment[]
@@index([createdById])
@@index([type])
@@index([publishedAt])
}
model NoticeAcknowledgment {
id String @id @default(uuid())
createdAt DateTime @default(now())
noticeId String
notice Notice @relation(fields: [noticeId], references: [id], onDelete: Cascade)
userId String
user User @relation("NoticeAcknowledgments", fields: [userId], references: [id], onDelete: Cascade)
acknowledgedAt DateTime @default(now())
@@unique([noticeId, userId])
@@index([noticeId])
@@index([userId])
}
\ No newline at end of file
export interface NotificationNewPayload {
notification: {
id: string;
type: string;
category: string;
title: string;
message: string;
actionUrl: string | null;
isBlocking: boolean;
createdAt: string;
};
}
export interface NotificationBlockingPayload {
notification: {
id: string;
type: string;
category: string;
title: string;
message: string;
actionUrl: string | null;
metadata: any;
createdAt: string;
};
}
export interface NotificationCountUpdatePayload {
unread: number;
blocking: number;
}
export interface MessageNewPayload {
conversationId: string;
message: {
id: string;
senderId: string;
senderName: string;
senderAvatar: string | null;
content: string | null;
type: string;
fileUrl: string | null;
fileName: string | null;
replyToId: string | null;
replyToPreview: any | null;
mentions: string[] | null;
createdAt: string;
};
}
export interface MessageTypingPayload {
conversationId: string;
userId: string;
}
export interface MessageReadPayload {
conversationId: string;
userId: string;
readAt: string;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment