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:
@@ -9,8 +9,9 @@ import {
|
||||
ParseIntPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
} 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 { CreateNotificationService } from './commands/CreateNotification.service';
|
||||
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
|
||||
@@ -22,9 +23,24 @@ class MarkAsReadDto {
|
||||
}
|
||||
|
||||
class GetNotificationsQueryDto {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
@IsOptional()
|
||||
@ToNumber()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@ToNumber()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
unreadOnly?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
@@ -46,8 +62,8 @@ export class NotificationsController {
|
||||
async getNotifications(@Query() query: GetNotificationsQueryDto) {
|
||||
const user = await this.tenancyContext.getSystemUser();
|
||||
const options = {
|
||||
limit: query.limit ? parseInt(query.limit, 10) : 20,
|
||||
offset: query.offset ? parseInt(query.offset, 10) : 0,
|
||||
limit: query.limit ?? 20,
|
||||
offset: query.offset ?? 0,
|
||||
unreadOnly: query.unreadOnly === 'true',
|
||||
category: query.category,
|
||||
};
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
|
||||
import { TenancyContext } from '../Tenancy/TenancyContext.service';
|
||||
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
|
||||
import { SocketModule } from '../Socket/Socket.module';
|
||||
import { Notification } from './models/Notification.model';
|
||||
import { NotificationsController } from './Notifications.controller';
|
||||
import { GetNotificationsService } from './queries/GetNotifications.service';
|
||||
import { CreateNotificationService } from './commands/CreateNotification.service';
|
||||
import { InventoryCostNotificationsSubscriber } from './subscribers/InventoryCostNotifications.subscriber';
|
||||
|
||||
const models = [RegisterTenancyModel(Notification)];
|
||||
import { OverdueSaleInvoiceNotificationsJob } from './jobs/OverdueSaleInvoiceNotifications.job';
|
||||
|
||||
@Module({
|
||||
imports: [TenancyDatabaseModule, SocketModule, ...models],
|
||||
imports: [TenancyDatabaseModule, SocketModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
GetNotificationsService,
|
||||
CreateNotificationService,
|
||||
InventoryCostNotificationsSubscriber,
|
||||
OverdueSaleInvoiceNotificationsJob,
|
||||
TenancyContext,
|
||||
],
|
||||
exports: [GetNotificationsService, CreateNotificationService, ...models],
|
||||
exports: [GetNotificationsService, CreateNotificationService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Notification } from '../models/Notification.model';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { TenantNotification } from '../models/Notification.model';
|
||||
|
||||
export interface CreateNotificationPayload {
|
||||
userId?: number | null;
|
||||
@@ -12,13 +13,18 @@ export interface CreateNotificationPayload {
|
||||
|
||||
@Injectable()
|
||||
export class CreateNotificationService {
|
||||
constructor(
|
||||
@Inject(TenantNotification.name)
|
||||
private readonly notificationModel: TenantModelProxy<typeof TenantNotification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new notification.
|
||||
* @param {CreateNotificationPayload} payload - Notification data
|
||||
* @returns {Promise<Notification>}
|
||||
* @returns {Promise<TenantNotification>}
|
||||
*/
|
||||
async createNotification(payload: CreateNotificationPayload): Promise<Notification> {
|
||||
const notification = await Notification.query().insert({
|
||||
async createNotification(payload: CreateNotificationPayload): Promise<TenantNotification> {
|
||||
const notification = await this.notificationModel().query().insert({
|
||||
userId: payload.userId ?? null,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
@@ -35,10 +41,11 @@ export class CreateNotificationService {
|
||||
* Marks a notification as read.
|
||||
* @param {number} notificationId - The notification ID
|
||||
* @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> {
|
||||
const notification = await Notification.query()
|
||||
async markAsRead(notificationId: number, userId: number): Promise<TenantNotification | null> {
|
||||
const notification = await this.notificationModel()
|
||||
.query()
|
||||
.findById(notificationId)
|
||||
.modify('forUser', userId);
|
||||
|
||||
@@ -46,11 +53,12 @@ export class CreateNotificationService {
|
||||
return null;
|
||||
}
|
||||
|
||||
await Notification.query()
|
||||
await this.notificationModel()
|
||||
.query()
|
||||
.findById(notificationId)
|
||||
.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
|
||||
*/
|
||||
async markAllAsRead(userId: number): Promise<number> {
|
||||
const result = await Notification.query()
|
||||
const result = await this.notificationModel()
|
||||
.query()
|
||||
.modify('forUser', userId)
|
||||
.modify('unread')
|
||||
.patch({ readAt: new Date() });
|
||||
@@ -74,7 +83,8 @@ export class CreateNotificationService {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteNotification(notificationId: number, userId: number): Promise<boolean> {
|
||||
const notification = await Notification.query()
|
||||
const notification = await this.notificationModel()
|
||||
.query()
|
||||
.findById(notificationId)
|
||||
.modify('forUser', userId);
|
||||
|
||||
@@ -82,7 +92,7 @@ export class CreateNotificationService {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Notification.query().deleteById(notificationId);
|
||||
await this.notificationModel().query().deleteById(notificationId);
|
||||
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 invoice’s 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 { 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 userId!: number | null;
|
||||
public title!: string;
|
||||
@@ -9,7 +10,7 @@ export class Notification extends TenantBaseModel {
|
||||
public type!: 'success' | 'info' | 'warning' | 'error';
|
||||
public category!: 'inventory' | 'billing' | 'system' | 'export' | 'report';
|
||||
public metadata!: Record<string, any> | null;
|
||||
public readAt!: Date | null;
|
||||
public readAt!: Date | string | null;
|
||||
public createdAt!: Date;
|
||||
public updatedAt!: Date;
|
||||
|
||||
@@ -20,6 +21,14 @@ export class Notification extends TenantBaseModel {
|
||||
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.
|
||||
*/
|
||||
@@ -96,15 +105,13 @@ export class Notification extends TenantBaseModel {
|
||||
* Relationship mapping.
|
||||
*/
|
||||
static get relationMappings() {
|
||||
const { User } = require('@/modules/UsersModule/models/User.model');
|
||||
|
||||
return {
|
||||
/**
|
||||
* Notification belongs to a user.
|
||||
* Notification belongs to a tenant user (`users` in the tenant database).
|
||||
*/
|
||||
user: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: User,
|
||||
modelClass: TenantUser,
|
||||
join: {
|
||||
from: 'notifications.userId',
|
||||
to: 'users.id',
|
||||
@@ -128,7 +135,14 @@ export class Notification extends TenantBaseModel {
|
||||
type: { type: 'string', enum: ['success', 'info', 'warning', 'error'] },
|
||||
category: { type: 'string', enum: ['inventory', 'billing', 'system', 'export', 'report'] },
|
||||
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' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Notification } from '../models/Notification.model';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { TenantNotification } from '../models/Notification.model';
|
||||
|
||||
interface GetNotificationsOptions {
|
||||
limit?: number;
|
||||
@@ -10,20 +11,26 @@ interface GetNotificationsOptions {
|
||||
|
||||
@Injectable()
|
||||
export class GetNotificationsService {
|
||||
constructor(
|
||||
@Inject(TenantNotification.name)
|
||||
private readonly notificationModel: TenantModelProxy<typeof TenantNotification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieves notifications for a user with pagination and filtering.
|
||||
* @param {number} userId - The user ID
|
||||
* @param {GetNotificationsOptions} options - Query options
|
||||
* @returns {Promise<{ notifications: Notification[]; total: number; unreadCount: number }>}
|
||||
* @returns {Promise<{ notifications: TenantNotification[]; total: number; unreadCount: number }>}
|
||||
*/
|
||||
async getNotifications(
|
||||
userId: number,
|
||||
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;
|
||||
|
||||
// Build base query for user's notifications
|
||||
let query = Notification.query()
|
||||
let query = this.notificationModel()
|
||||
.query()
|
||||
.modify('forUser', userId)
|
||||
.modify('newestFirst');
|
||||
|
||||
@@ -38,7 +45,8 @@ export class GetNotificationsService {
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const countQuery = Notification.query()
|
||||
const countQuery = this.notificationModel()
|
||||
.query()
|
||||
.modify('forUser', userId)
|
||||
.modify(unreadOnly ? 'unread' : 'newestFirst');
|
||||
|
||||
@@ -65,7 +73,8 @@ export class GetNotificationsService {
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getUnreadCount(userId: number): Promise<number> {
|
||||
return Notification.query()
|
||||
return this.notificationModel()
|
||||
.query()
|
||||
.modify('forUser', userId)
|
||||
.modify('unread')
|
||||
.resultSize();
|
||||
|
||||
@@ -40,6 +40,7 @@ import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceive
|
||||
import { Model } from 'objection';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TenantUser } from './models/TenantUser.model';
|
||||
import { TenantNotification } from '@/modules/Notifications/models/Notification.model';
|
||||
|
||||
const models = [
|
||||
Item,
|
||||
@@ -80,6 +81,7 @@ const models = [
|
||||
PaymentReceived,
|
||||
PaymentReceivedEntry,
|
||||
TenantUser,
|
||||
TenantNotification,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Icon, Intent, Tooltip, Position } from '@blueprintjs/core';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import moment from 'moment';
|
||||
import { Notification } from '@/hooks/query/notifications';
|
||||
import styles from './NotificationsDrawer.module.scss';
|
||||
|
||||
@@ -66,7 +66,7 @@ export function NotificationItem({ notification, onClick, onDelete }: Notificati
|
||||
position={Position.TOP}
|
||||
>
|
||||
<span className={styles.timestamp}>
|
||||
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
|
||||
{moment(notification.createdAt).fromNow()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user