diff --git a/packages/server/src/modules/Notifications/Notifications.controller.ts b/packages/server/src/modules/Notifications/Notifications.controller.ts index ab0ae9f82..5acbceb76 100644 --- a/packages/server/src/modules/Notifications/Notifications.controller.ts +++ b/packages/server/src/modules/Notifications/Notifications.controller.ts @@ -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, }; diff --git a/packages/server/src/modules/Notifications/Notifications.module.ts b/packages/server/src/modules/Notifications/Notifications.module.ts index 0fde34c3b..1c1fd69a0 100644 --- a/packages/server/src/modules/Notifications/Notifications.module.ts +++ b/packages/server/src/modules/Notifications/Notifications.module.ts @@ -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 {} diff --git a/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts b/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts index 3bc4a8d0e..45fe66f63 100644 --- a/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts +++ b/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts @@ -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, + ) {} + /** * Creates a new notification. * @param {CreateNotificationPayload} payload - Notification data - * @returns {Promise} + * @returns {Promise} */ - async createNotification(payload: CreateNotificationPayload): Promise { - const notification = await Notification.query().insert({ + async createNotification(payload: CreateNotificationPayload): Promise { + 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} + * @returns {Promise} */ - async markAsRead(notificationId: number, userId: number): Promise { - const notification = await Notification.query() + async markAsRead(notificationId: number, userId: number): Promise { + 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 of notifications marked as read */ async markAllAsRead(userId: number): Promise { - 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} */ async deleteNotification(notificationId: number, userId: number): Promise { - 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; } } diff --git a/packages/server/src/modules/Notifications/jobs/OverdueSaleInvoiceNotifications.job.ts b/packages/server/src/modules/Notifications/jobs/OverdueSaleInvoiceNotifications.job.ts new file mode 100644 index 000000000..b12a653b7 --- /dev/null +++ b/packages/server/src/modules/Notifications/jobs/OverdueSaleInvoiceNotifications.job.ts @@ -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, + 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), + ); + } + }); + } + } +} diff --git a/packages/server/src/modules/Notifications/models/Notification.model.ts b/packages/server/src/modules/Notifications/models/Notification.model.ts index ccdec57f6..bb3e523e8 100644 --- a/packages/server/src/modules/Notifications/models/Notification.model.ts +++ b/packages/server/src/modules/Notifications/models/Notification.model.ts @@ -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 | 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' }, }, diff --git a/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts b/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts index 695472ef8..fb3842a41 100644 --- a/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts +++ b/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts @@ -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, + ) {} + /** * 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} */ async getUnreadCount(userId: number): Promise { - return Notification.query() + return this.notificationModel() + .query() .modify('forUser', userId) .modify('unread') .resultSize(); diff --git a/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index 0bc1d639c..9919b4b1f 100644 --- a/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -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, ]; /** diff --git a/packages/webapp/src/containers/Drawers/NotificationsDrawer/NotificationItem.tsx b/packages/webapp/src/containers/Drawers/NotificationsDrawer/NotificationItem.tsx index 332459c97..68d8f6529 100644 --- a/packages/webapp/src/containers/Drawers/NotificationsDrawer/NotificationItem.tsx +++ b/packages/webapp/src/containers/Drawers/NotificationsDrawer/NotificationItem.tsx @@ -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} > - {formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })} + {moment(notification.createdAt).fromNow()}