1
0

feat(notifications): overdue sale invoice job and notification fixes

- Add daily cron to notify invoice owners when delivered invoices become overdue
- Fix TenantNotification jsonSchema/jsonAttributes for readAt patches (MySQL + Objection)
- Register TenantNotification model; adjust notifications API and drawer item UI

Made-with: Cursor
This commit is contained in:
Ahmed Bouhuolia
2026-04-08 21:22:24 +02:00
parent a532b9fc6b
commit 4e48caebd7
8 changed files with 169 additions and 40 deletions
@@ -9,8 +9,9 @@ import {
ParseIntPipe, ParseIntPipe,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsIn, IsInt, IsString, Min } from 'class-validator';
import { ToNumber, IsOptional } from '@/common/decorators/Validators';
import { GetNotificationsService } from './queries/GetNotifications.service'; import { GetNotificationsService } from './queries/GetNotifications.service';
import { CreateNotificationService } from './commands/CreateNotification.service'; import { CreateNotificationService } from './commands/CreateNotification.service';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard'; import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
@@ -22,9 +23,24 @@ class MarkAsReadDto {
} }
class GetNotificationsQueryDto { class GetNotificationsQueryDto {
limit?: string; @IsOptional()
offset?: string; @ToNumber()
@IsInt()
@Min(1)
limit?: number;
@IsOptional()
@ToNumber()
@IsInt()
@Min(0)
offset?: number;
@IsOptional()
@IsIn(['true', 'false'])
unreadOnly?: string; unreadOnly?: string;
@IsOptional()
@IsString()
category?: string; category?: string;
} }
@@ -46,8 +62,8 @@ export class NotificationsController {
async getNotifications(@Query() query: GetNotificationsQueryDto) { async getNotifications(@Query() query: GetNotificationsQueryDto) {
const user = await this.tenancyContext.getSystemUser(); const user = await this.tenancyContext.getSystemUser();
const options = { const options = {
limit: query.limit ? parseInt(query.limit, 10) : 20, limit: query.limit ?? 20,
offset: query.offset ? parseInt(query.offset, 10) : 0, offset: query.offset ?? 0,
unreadOnly: query.unreadOnly === 'true', unreadOnly: query.unreadOnly === 'true',
category: query.category, category: query.category,
}; };
@@ -1,25 +1,23 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { TenancyContext } from '../Tenancy/TenancyContext.service'; import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { SocketModule } from '../Socket/Socket.module'; import { SocketModule } from '../Socket/Socket.module';
import { Notification } from './models/Notification.model';
import { NotificationsController } from './Notifications.controller'; import { NotificationsController } from './Notifications.controller';
import { GetNotificationsService } from './queries/GetNotifications.service'; import { GetNotificationsService } from './queries/GetNotifications.service';
import { CreateNotificationService } from './commands/CreateNotification.service'; import { CreateNotificationService } from './commands/CreateNotification.service';
import { InventoryCostNotificationsSubscriber } from './subscribers/InventoryCostNotifications.subscriber'; import { InventoryCostNotificationsSubscriber } from './subscribers/InventoryCostNotifications.subscriber';
import { OverdueSaleInvoiceNotificationsJob } from './jobs/OverdueSaleInvoiceNotifications.job';
const models = [RegisterTenancyModel(Notification)];
@Module({ @Module({
imports: [TenancyDatabaseModule, SocketModule, ...models], imports: [TenancyDatabaseModule, SocketModule],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [ providers: [
GetNotificationsService, GetNotificationsService,
CreateNotificationService, CreateNotificationService,
InventoryCostNotificationsSubscriber, InventoryCostNotificationsSubscriber,
OverdueSaleInvoiceNotificationsJob,
TenancyContext, TenancyContext,
], ],
exports: [GetNotificationsService, CreateNotificationService, ...models], exports: [GetNotificationsService, CreateNotificationService],
}) })
export class NotificationsModule {} export class NotificationsModule {}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Notification } from '../models/Notification.model'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TenantNotification } from '../models/Notification.model';
export interface CreateNotificationPayload { export interface CreateNotificationPayload {
userId?: number | null; userId?: number | null;
@@ -12,13 +13,18 @@ export interface CreateNotificationPayload {
@Injectable() @Injectable()
export class CreateNotificationService { export class CreateNotificationService {
constructor(
@Inject(TenantNotification.name)
private readonly notificationModel: TenantModelProxy<typeof TenantNotification>,
) {}
/** /**
* Creates a new notification. * Creates a new notification.
* @param {CreateNotificationPayload} payload - Notification data * @param {CreateNotificationPayload} payload - Notification data
* @returns {Promise<Notification>} * @returns {Promise<TenantNotification>}
*/ */
async createNotification(payload: CreateNotificationPayload): Promise<Notification> { async createNotification(payload: CreateNotificationPayload): Promise<TenantNotification> {
const notification = await Notification.query().insert({ const notification = await this.notificationModel().query().insert({
userId: payload.userId ?? null, userId: payload.userId ?? null,
title: payload.title, title: payload.title,
message: payload.message, message: payload.message,
@@ -35,10 +41,11 @@ export class CreateNotificationService {
* Marks a notification as read. * Marks a notification as read.
* @param {number} notificationId - The notification ID * @param {number} notificationId - The notification ID
* @param {number} userId - The user ID (for authorization) * @param {number} userId - The user ID (for authorization)
* @returns {Promise<Notification | null>} * @returns {Promise<TenantNotification | null>}
*/ */
async markAsRead(notificationId: number, userId: number): Promise<Notification | null> { async markAsRead(notificationId: number, userId: number): Promise<TenantNotification | null> {
const notification = await Notification.query() const notification = await this.notificationModel()
.query()
.findById(notificationId) .findById(notificationId)
.modify('forUser', userId); .modify('forUser', userId);
@@ -46,11 +53,12 @@ export class CreateNotificationService {
return null; return null;
} }
await Notification.query() await this.notificationModel()
.query()
.findById(notificationId) .findById(notificationId)
.patch({ readAt: new Date() }); .patch({ readAt: new Date() });
return Notification.query().findById(notificationId); return this.notificationModel().query().findById(notificationId);
} }
/** /**
@@ -59,7 +67,8 @@ export class CreateNotificationService {
* @returns {Promise<number>} - Number of notifications marked as read * @returns {Promise<number>} - Number of notifications marked as read
*/ */
async markAllAsRead(userId: number): Promise<number> { async markAllAsRead(userId: number): Promise<number> {
const result = await Notification.query() const result = await this.notificationModel()
.query()
.modify('forUser', userId) .modify('forUser', userId)
.modify('unread') .modify('unread')
.patch({ readAt: new Date() }); .patch({ readAt: new Date() });
@@ -74,7 +83,8 @@ export class CreateNotificationService {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async deleteNotification(notificationId: number, userId: number): Promise<boolean> { async deleteNotification(notificationId: number, userId: number): Promise<boolean> {
const notification = await Notification.query() const notification = await this.notificationModel()
.query()
.findById(notificationId) .findById(notificationId)
.modify('forUser', userId); .modify('forUser', userId);
@@ -82,7 +92,7 @@ export class CreateNotificationService {
return false; return false;
} }
await Notification.query().deleteById(notificationId); await this.notificationModel().query().deleteById(notificationId);
return true; return true;
} }
} }
@@ -0,0 +1,80 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ClsService } from 'nestjs-cls';
import * as moment from 'moment';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { CreateNotificationService } from '../commands/CreateNotification.service';
import { SocketGateway } from '@/modules/Socket/Socket.gateway';
/**
* Once per day: notify invoice authors when a delivered, still-due invoices due date
* was yesterday (first calendar day overdue).
*/
@Injectable()
export class OverdueSaleInvoiceNotificationsJob {
private readonly logger = new Logger(OverdueSaleInvoiceNotificationsJob.name);
constructor(
private readonly clsService: ClsService,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
@Inject(SaleInvoice.name)
private readonly saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>,
private readonly createNotificationService: CreateNotificationService,
private readonly socketGateway: SocketGateway,
) {}
@Cron('0 8 * * *')
async notifyNewlyOverdueInvoices() {
const yesterday = moment().subtract(1, 'day').format('YYYY-MM-DD');
const tenants = await this.tenantModel
.query()
.whereNotNull('seededAt')
.select('organizationId');
for (const tenant of tenants) {
await this.clsService.run(async () => {
this.clsService.set('organizationId', tenant.organizationId);
try {
const invoices = await this.saleInvoiceModel()
.query()
.modify('delivered')
.modify('dueInvoices')
.where('dueDate', yesterday);
for (const invoice of invoices) {
const userId = invoice.userId;
if (!userId) {
continue;
}
const notification = await this.createNotificationService.createNotification({
userId,
title: 'Invoice overdue',
message: `Invoice ${invoice.invoiceNo} is now overdue.`,
type: 'warning',
category: 'billing',
metadata: {
saleInvoiceId: invoice.id,
invoiceNo: invoice.invoiceNo,
dueDate: invoice.dueDate,
event: 'saleInvoice.becameOverdue',
},
});
this.socketGateway.emitNotification(userId, notification);
}
} catch (error) {
this.logger.error(
`Overdue invoice notifications failed for tenant ${tenant.organizationId}`,
error instanceof Error ? error.stack : String(error),
);
}
});
}
}
}
@@ -1,7 +1,8 @@
import { Model } from 'objection'; import { Model } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { TenantUser } from '../../Tenancy/TenancyModels/models/TenantUser.model';
export class Notification extends TenantBaseModel { export class TenantNotification extends TenantBaseModel {
public id!: number; public id!: number;
public userId!: number | null; public userId!: number | null;
public title!: string; public title!: string;
@@ -9,7 +10,7 @@ export class Notification extends TenantBaseModel {
public type!: 'success' | 'info' | 'warning' | 'error'; public type!: 'success' | 'info' | 'warning' | 'error';
public category!: 'inventory' | 'billing' | 'system' | 'export' | 'report'; public category!: 'inventory' | 'billing' | 'system' | 'export' | 'report';
public metadata!: Record<string, any> | null; public metadata!: Record<string, any> | null;
public readAt!: Date | null; public readAt!: Date | string | null;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
@@ -20,6 +21,14 @@ export class Notification extends TenantBaseModel {
return 'notifications'; return 'notifications';
} }
/**
* JSON columns only. Without this, Objection infers JSON attributes from jsonSchema; `readAt`
* would match `type: object` in anyOf and get JSON.stringify'd, breaking MySQL DATETIME updates.
*/
static get jsonAttributes() {
return ['metadata'];
}
/** /**
* Timestamps columns. * Timestamps columns.
*/ */
@@ -96,15 +105,13 @@ export class Notification extends TenantBaseModel {
* Relationship mapping. * Relationship mapping.
*/ */
static get relationMappings() { static get relationMappings() {
const { User } = require('@/modules/UsersModule/models/User.model');
return { return {
/** /**
* Notification belongs to a user. * Notification belongs to a tenant user (`users` in the tenant database).
*/ */
user: { user: {
relation: Model.BelongsToOneRelation, relation: Model.BelongsToOneRelation,
modelClass: User, modelClass: TenantUser,
join: { join: {
from: 'notifications.userId', from: 'notifications.userId',
to: 'users.id', to: 'users.id',
@@ -128,7 +135,14 @@ export class Notification extends TenantBaseModel {
type: { type: 'string', enum: ['success', 'info', 'warning', 'error'] }, type: { type: 'string', enum: ['success', 'info', 'warning', 'error'] },
category: { type: 'string', enum: ['inventory', 'billing', 'system', 'export', 'report'] }, category: { type: 'string', enum: ['inventory', 'billing', 'system', 'export', 'report'] },
metadata: { type: ['object', 'null'] }, metadata: { type: ['object', 'null'] },
readAt: { type: ['string', 'null'], format: 'date-time' }, // `Date` is allowed on patch; mysql2 binds it as DATETIME (see jsonAttributes above).
readAt: {
anyOf: [
{ type: 'null' },
{ type: 'string', format: 'date-time' },
{ type: 'object' },
],
},
createdAt: { type: 'string', format: 'date-time' }, createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' },
}, },
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Notification } from '../models/Notification.model'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TenantNotification } from '../models/Notification.model';
interface GetNotificationsOptions { interface GetNotificationsOptions {
limit?: number; limit?: number;
@@ -10,20 +11,26 @@ interface GetNotificationsOptions {
@Injectable() @Injectable()
export class GetNotificationsService { export class GetNotificationsService {
constructor(
@Inject(TenantNotification.name)
private readonly notificationModel: TenantModelProxy<typeof TenantNotification>,
) {}
/** /**
* Retrieves notifications for a user with pagination and filtering. * Retrieves notifications for a user with pagination and filtering.
* @param {number} userId - The user ID * @param {number} userId - The user ID
* @param {GetNotificationsOptions} options - Query options * @param {GetNotificationsOptions} options - Query options
* @returns {Promise<{ notifications: Notification[]; total: number; unreadCount: number }>} * @returns {Promise<{ notifications: TenantNotification[]; total: number; unreadCount: number }>}
*/ */
async getNotifications( async getNotifications(
userId: number, userId: number,
options: GetNotificationsOptions = {}, options: GetNotificationsOptions = {},
): Promise<{ notifications: Notification[]; total: number; unreadCount: number }> { ): Promise<{ notifications: TenantNotification[]; total: number; unreadCount: number }> {
const { limit = 20, offset = 0, unreadOnly = false, category } = options; const { limit = 20, offset = 0, unreadOnly = false, category } = options;
// Build base query for user's notifications // Build base query for user's notifications
let query = Notification.query() let query = this.notificationModel()
.query()
.modify('forUser', userId) .modify('forUser', userId)
.modify('newestFirst'); .modify('newestFirst');
@@ -38,7 +45,8 @@ export class GetNotificationsService {
} }
// Get total count for pagination // Get total count for pagination
const countQuery = Notification.query() const countQuery = this.notificationModel()
.query()
.modify('forUser', userId) .modify('forUser', userId)
.modify(unreadOnly ? 'unread' : 'newestFirst'); .modify(unreadOnly ? 'unread' : 'newestFirst');
@@ -65,7 +73,8 @@ export class GetNotificationsService {
* @returns {Promise<number>} * @returns {Promise<number>}
*/ */
async getUnreadCount(userId: number): Promise<number> { async getUnreadCount(userId: number): Promise<number> {
return Notification.query() return this.notificationModel()
.query()
.modify('forUser', userId) .modify('forUser', userId)
.modify('unread') .modify('unread')
.resultSize(); .resultSize();
@@ -40,6 +40,7 @@ import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceive
import { Model } from 'objection'; import { Model } from 'objection';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { TenantUser } from './models/TenantUser.model'; import { TenantUser } from './models/TenantUser.model';
import { TenantNotification } from '@/modules/Notifications/models/Notification.model';
const models = [ const models = [
Item, Item,
@@ -80,6 +81,7 @@ const models = [
PaymentReceived, PaymentReceived,
PaymentReceivedEntry, PaymentReceivedEntry,
TenantUser, TenantUser,
TenantNotification,
]; ];
/** /**
@@ -1,7 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { Icon, Intent, Tooltip, Position } from '@blueprintjs/core'; import { Icon, Intent, Tooltip, Position } from '@blueprintjs/core';
import { formatDistanceToNow } from 'date-fns'; import moment from 'moment';
import { Notification } from '@/hooks/query/notifications'; import { Notification } from '@/hooks/query/notifications';
import styles from './NotificationsDrawer.module.scss'; import styles from './NotificationsDrawer.module.scss';
@@ -66,7 +66,7 @@ export function NotificationItem({ notification, onClick, onDelete }: Notificati
position={Position.TOP} position={Position.TOP}
> >
<span className={styles.timestamp}> <span className={styles.timestamp}>
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })} {moment(notification.createdAt).fromNow()}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>