feat(notify): base setup for notifications module
This commit is contained in:
parent
109a80a0f0
commit
dc8c4d048c
|
|
@ -2398,7 +2398,8 @@
|
||||||
"UNPAID",
|
"UNPAID",
|
||||||
"BEREAVEMENT",
|
"BEREAVEMENT",
|
||||||
"PARENTAL",
|
"PARENTAL",
|
||||||
"LEGAL"
|
"LEGAL",
|
||||||
|
"WEDDING"
|
||||||
],
|
],
|
||||||
"description": "type of leave request for an accounting perception"
|
"description": "type of leave request for an accounting perception"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { BusinessLogicsModule } from './modules/business-logics/business-logics.
|
||||||
import { CsvExportModule } from './modules/exports/csv-exports.module';
|
import { CsvExportModule } from './modules/exports/csv-exports.module';
|
||||||
import { CustomersModule } from './modules/customers/customers.module';
|
import { CustomersModule } from './modules/customers/customers.module';
|
||||||
import { EmployeesModule } from './modules/employees/employees.module';
|
import { EmployeesModule } from './modules/employees/employees.module';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { ExpensesModule } from './modules/expenses/expenses.module';
|
import { ExpensesModule } from './modules/expenses/expenses.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HealthController } from './health/health.controller';
|
import { HealthController } from './health/health.controller';
|
||||||
|
|
@ -31,7 +30,6 @@ import { UsersModule } from './modules/users-management/users.module';
|
||||||
CsvExportModule,
|
CsvExportModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
EmployeesModule,
|
EmployeesModule,
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
ExpensesModule,
|
ExpensesModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
LeaveRequestsModule,
|
LeaveRequestsModule,
|
||||||
|
|
|
||||||
23
src/modules/notifications/notifications.controller.ts
Normal file
23
src/modules/notifications/notifications.controller.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Controller, Get, Req, Sse, UseGuards,
|
||||||
|
MessageEvent as NestMessageEvent } from "@nestjs/common";
|
||||||
|
import { JwtAuthGuard } from "../authentication/guards/jwt-auth.guard";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('notifications')
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notificationsService: NotificationsService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
async summary(@Req() req) {
|
||||||
|
return this.notificationsService.summary(String(req.user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('stream')
|
||||||
|
stream(@Req() req): Observable<NestMessageEvent> {
|
||||||
|
const userId = String(req.user.id);
|
||||||
|
return this.notificationsService.stream(userId).pipe(map((data): NestMessageEvent => ({ data })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { NotificationsController } from "./notifications.controller";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NotificationsService],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
|
|
@ -1,73 +1,62 @@
|
||||||
import { InjectQueue } from "@nestjs/bullmq";
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Queue } from "bullmq";
|
import { Subject } from "rxjs";
|
||||||
import { TimesheetsService } from "../timesheets/services/timesheets.service";
|
import { NotificationCard } from "./notifications.types";
|
||||||
import { ShiftsService } from "../shifts/services/shifts.service";
|
|
||||||
import { ExpensesService } from "../expenses/services/expenses.service";
|
|
||||||
import { EmployeesService } from "../employees/services/employees.service";
|
|
||||||
import { LeaveRequestsService } from "../leave-requests/services/leave-requests.service";
|
|
||||||
|
|
||||||
export interface DigestItem {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectQueue('notifications') private readonly queue: Queue,
|
|
||||||
private readonly timesheetsService : TimesheetsService,
|
|
||||||
private readonly shiftsService : ShiftsService,
|
|
||||||
private readonly expensesService : ExpensesService,
|
|
||||||
private readonly employeesService : EmployeesService,
|
|
||||||
private readonly leaveRequestsService: LeaveRequestsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async queueNotification(channel: string, payload: Record<string,any>): Promise<void> {
|
//Server-Sent Events FLUX and a buffer per user
|
||||||
await this.queue.add(channel, payload);
|
private streams = new Map<string, Subject<NotificationCard>>();
|
||||||
this.logger.debug(`Enqueued notification on channel= "${channel}"`);
|
private buffers = new Map<string, NotificationCard[]>();
|
||||||
|
private readonly BUFFER_MAX = Number(process.env.NOTIF_BUFFER_MAX ?? 50);
|
||||||
|
|
||||||
|
private getOrCreateStream(userId: string): Subject<NotificationCard> {
|
||||||
|
let stream = this.streams.get(userId);
|
||||||
|
if (!stream){
|
||||||
|
stream = new Subject<NotificationCard>();
|
||||||
|
this.streams.set(userId, stream);
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
private getOrCreateBuffer(userId: string){
|
||||||
|
let buffer = this.buffers.get(userId);
|
||||||
|
if(!buffer) {
|
||||||
|
buffer = [];
|
||||||
|
this.buffers.set(userId, buffer);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildWeeklyDigest(): Promise<{recipients: string[], items: DigestItem[]}> {
|
//in-app pushes and keep a small history
|
||||||
//TO DO add logic of missing shifts, overtime alert, vacation alerts, leave-requests, etc...
|
notify(userId: string, card: NotificationCard) {
|
||||||
//fetching all business datas
|
const buffer = this.getOrCreateBuffer(userId);
|
||||||
//const missingShifts = await this.timesheetsService.findMissingShftsLastWeek();
|
buffer.unshift(card);
|
||||||
|
if (buffer.length > this.BUFFER_MAX) {
|
||||||
const items: DigestItem[] = [
|
buffer.length = this.BUFFER_MAX;
|
||||||
//example:
|
}
|
||||||
{title: 'Carte de temps incomplete', description: 'Des employes n`ont pas saisi leurs quarts de travail'},
|
this.getOrCreateStream(userId).next(card);
|
||||||
{title: 'Overtime détecté', description: '....'},
|
this.logger.debug(`Notification in-app => user: ${userId} (${card.type})`);
|
||||||
];
|
|
||||||
|
|
||||||
const recipients = [
|
|
||||||
//exemple : await this.userService.findSupervisorsEmails();
|
|
||||||
'supervisor@targointernet.com',
|
|
||||||
'accounting@targointernet.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
return {recipients, items}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//SSE flux for current user
|
||||||
async buildMonthlyDigest(): Promise<{ recipients: string[], items: DigestItem[]}> {
|
stream(userId: string) {
|
||||||
//const anniversaries = await this.employeesService.findAnniversariesthisMonth();
|
return this.getOrCreateStream(userId).asObservable();
|
||||||
//const totalOvertime = await this.timesheetsService.calculateTotalOvertimeThisMonth();
|
}
|
||||||
|
|
||||||
const items: DigestItem[] = [
|
//return a summary of notifications kept in memory
|
||||||
{title: '5 ans d`ancienneté', description:'Marc-André, Jessy'},
|
async summary(userId: string): Promise<NotificationCard[]> {
|
||||||
{title: '10 ans d`ancienneté', description:'Kadi, Maxime'},
|
return this.getOrCreateBuffer(userId);
|
||||||
{title: 'Calendrier Annuel', description: 'Nouveau calendrier de l`an prochain maintenant disponible!'},
|
}
|
||||||
// ...
|
|
||||||
];
|
//clear buffers from memory
|
||||||
|
clear(userId: string) {
|
||||||
const recipients = [
|
this.buffers.set(userId, []);
|
||||||
'allemployees@targointernent.com',
|
}
|
||||||
];
|
|
||||||
|
onModuleDestroy() {
|
||||||
return { recipients, items };
|
for (const stream of this.streams.values()) stream.complete();
|
||||||
|
this.streams.clear();
|
||||||
|
this.buffers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { Cron, Interval, SchedulerRegistry } from "@nestjs/schedule";
|
|
||||||
import { NotificationsService as Orchestrator } from './notifications.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationsService {
|
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly schedulerRegistry: SchedulerRegistry,
|
|
||||||
private readonly orchestrator: Orchestrator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
//cache purging
|
|
||||||
//@TimeOut(15_000)
|
|
||||||
async onStartup() {
|
|
||||||
this.logger.debug('Startup cleanup: initial verifications');
|
|
||||||
//clean up of useless cache on start up
|
|
||||||
}
|
|
||||||
|
|
||||||
//Q monitoring
|
|
||||||
@Interval(300_000)
|
|
||||||
async monitorQueueHealth() {
|
|
||||||
this.logger.debug('monitoring notification queue')
|
|
||||||
//this.orchestrator.checkQueueLength();
|
|
||||||
//monitor backlog for overload and such
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//weekly cron jobs
|
|
||||||
@Cron('0 0 8 * * 1', {
|
|
||||||
name: 'weeklyDigest',
|
|
||||||
timeZone: 'America/Toronto',
|
|
||||||
})
|
|
||||||
async sendWeeklyDigest() {
|
|
||||||
this.logger.debug('Building Weekly digest');
|
|
||||||
const { recipients, items } = await this.orchestrator.buildWeeklyDigest();
|
|
||||||
await this.orchestrator.queueNotification('email', {
|
|
||||||
to: recipients,
|
|
||||||
subject: '[Journal Hebdo] Sommaire de la semaine dernière',
|
|
||||||
template: 'weekly-digest',
|
|
||||||
context: { items },
|
|
||||||
});
|
|
||||||
this.logger.debug('Weekly digest Queued');
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableWeeklyDigest() {
|
|
||||||
const job = this.schedulerRegistry.getCronJob('weeklyDigest');
|
|
||||||
job.stop();
|
|
||||||
this.logger.debug(`Weekly digest stopped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableWeeklyDigest() {
|
|
||||||
const job = this.schedulerRegistry.getCronJob('weeklyDigest');
|
|
||||||
job.start();
|
|
||||||
this.logger.debug(`Weekly digest started`);
|
|
||||||
}
|
|
||||||
|
|
||||||
//monthly cron jobs
|
|
||||||
@Cron('0 0 9 1 * *', {
|
|
||||||
name: 'monthlyDigest',
|
|
||||||
timeZone: 'America/Toronto',
|
|
||||||
})
|
|
||||||
async sendMonthlyDigest() {
|
|
||||||
this.logger.debug('Building Monthly digest');
|
|
||||||
const {recipients, items} = await this.orchestrator.buildMonthlyDigest();
|
|
||||||
await this.orchestrator.queueNotification('email', {
|
|
||||||
to: recipients,
|
|
||||||
subject: '[Journal Mensuel] Sommaire du mois',
|
|
||||||
template: 'monthly-digest',
|
|
||||||
context: { items },
|
|
||||||
|
|
||||||
})
|
|
||||||
this.logger.debug('Monthly digest queued');
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableMonthlyDigest() {
|
|
||||||
const job = this.schedulerRegistry.getCronJob('monthlyDigest');
|
|
||||||
job.stop();
|
|
||||||
this.logger.debug(`Monthly digest stopped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableMonthlyDigest() {
|
|
||||||
const job = this.schedulerRegistry.getCronJob('monthlyDigest');
|
|
||||||
job.start();
|
|
||||||
this.logger.debug(`Monthly digest stopped`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeOut(arg0: number): (target: Orchestrator, propertyKey: "onStartup", descriptor: TypedPropertyDescriptor<() => Promise<void>>) => void | TypedPropertyDescriptor<() => Promise<void>> {
|
|
||||||
throw new Error("Function not implemented.");
|
|
||||||
}
|
|
||||||
9
src/modules/notifications/notifications.types.ts
Normal file
9
src/modules/notifications/notifications.types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export type NotificationCard = {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
severity?: 'info'|'warn'|'error';
|
||||||
|
icon?: string;
|
||||||
|
link?: string;
|
||||||
|
meta?: Record<string, any>
|
||||||
|
ts: string; //new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
@ -36,7 +36,6 @@ export class PayPeriodsController {
|
||||||
@ApiOperation({ summary: 'detailed view of a pay period'})
|
@ApiOperation({ summary: 'detailed view of a pay period'})
|
||||||
@ApiResponse({ status: 200,description: 'Pay period overview found', type: PayPeriodOverviewDto })
|
@ApiResponse({ status: 200,description: 'Pay period overview found', type: PayPeriodOverviewDto })
|
||||||
@ApiResponse({status: 400, description: 'Pay period not found' })
|
@ApiResponse({status: 400, description: 'Pay period not found' })
|
||||||
|
|
||||||
async getOverview(@Param('periodNumber', ParseIntPipe) periodNumber: number):
|
async getOverview(@Param('periodNumber', ParseIntPipe) periodNumber: number):
|
||||||
Promise<PayPeriodOverviewDto> {
|
Promise<PayPeriodOverviewDto> {
|
||||||
return this.overviewService.getOverview(periodNumber);
|
return this.overviewService.getOverview(periodNumber);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user