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'; ...@@ -33,6 +33,11 @@ import { BountiesModule } from './modules/bounties/bounties.module';
import { AdjustmentsModule } from './modules/adjustments/adjustments.module'; import { AdjustmentsModule } from './modules/adjustments/adjustments.module';
import { PayrollModule } from './modules/payroll/payroll.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
...@@ -69,6 +74,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -69,6 +74,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
BountiesModule, BountiesModule,
AdjustmentsModule, AdjustmentsModule,
PayrollModule, PayrollModule,
// Phase 1E
NotificationsModule,
MessagesModule,
NoticesModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { 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
This diff is collapsed.
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
This diff is collapsed.
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