1
0
This commit is contained in:
Ahmed Bouhuolia
2026-04-13 23:31:21 +02:00
parent a532b9fc6b
commit b9c914dc67
8 changed files with 198 additions and 38 deletions
@@ -113,6 +113,7 @@ import { AppThrottleModule } from './AppThrottle.module';
rootPath: join(__dirname, '../../..', 'public'),
serveRoot: '/public',
}),
ConfigModule.forRoot({
envFilePath: '.env',
load: config,
@@ -11,24 +11,56 @@ import {
HttpStatus,
Inject,
} from '@nestjs/common';
import { IsNumber, IsString, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { GetNotificationsService } from './queries/GetNotifications.service';
import { CreateNotificationService } from './commands/CreateNotification.service';
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { IsOptional } from '@/common/decorators/Validators';
class MarkAsReadDto {
notificationId: number;
}
class GetNotificationsQueryDto {
limit?: string;
offset?: string;
@ApiPropertyOptional({ description: 'Number of notifications per page', example: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
limit?: number;
@ApiPropertyOptional({ description: 'Offset for pagination', example: 0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
offset?: number;
@ApiPropertyOptional({ description: 'Filter to only unread notifications' })
@IsOptional()
@IsString()
@IsIn(['true', 'false'])
unreadOnly?: string;
@ApiPropertyOptional({ description: 'Filter by notification category' })
@IsOptional()
@IsString()
category?: string;
}
@Controller('notifications')
@ApiTags('Notifications')
@ApiCommonHeaders()
@UseGuards(AuthorizationGuard, PermissionGuard)
export class NotificationsController {
constructor(
@@ -43,11 +75,43 @@ export class NotificationsController {
* @returns {Promise<{ notifications: Notification[]; total: number; unreadCount: number }>}
*/
@Get()
@ApiOperation({
summary: 'Retrieves the list of notifications for the current user.',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of notifications per page',
})
@ApiQuery({
name: 'offset',
required: false,
type: Number,
description: 'Offset for pagination',
})
@ApiQuery({
name: 'unreadOnly',
required: false,
type: Boolean,
description: 'Filter to only unread notifications',
})
@ApiQuery({
name: 'category',
required: false,
type: String,
description: 'Filter by notification category',
})
@ApiResponse({
status: 200,
description:
'The notifications list has been successfully retrieved.',
})
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,
};
@@ -60,6 +124,15 @@ export class NotificationsController {
* @returns {Promise<{ count: number }>}
*/
@Get('unread-count')
@ApiOperation({
summary:
'Gets the count of unread notifications for the current user.',
})
@ApiResponse({
status: 200,
description:
'The unread notifications count has been successfully retrieved.',
})
async getUnreadCount() {
const user = await this.tenancyContext.getSystemUser();
const count = await this.getNotificationsService.getUnreadCount(user.id);
@@ -73,6 +146,16 @@ export class NotificationsController {
*/
@Post(':id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Marks a notification as read.' })
@ApiParam({
name: 'id',
type: Number,
description: 'The notification id',
})
@ApiResponse({
status: 200,
description: 'The notification has been successfully marked as read.',
})
async markAsRead(@Param('id', ParseIntPipe) notificationId: number) {
const user = await this.tenancyContext.getSystemUser();
const notification = await this.createNotificationService.markAsRead(
@@ -88,6 +171,15 @@ export class NotificationsController {
*/
@Post('read-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'Marks all notifications as read for the current user.',
})
@ApiResponse({
status: 200,
description:
'All notifications have been successfully marked as read.',
})
async markAllAsRead() {
const user = await this.tenancyContext.getSystemUser();
const markedAsRead = await this.createNotificationService.markAllAsRead(
@@ -103,6 +195,16 @@ export class NotificationsController {
*/
@Post(':id/delete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Deletes a notification.' })
@ApiParam({
name: 'id',
type: Number,
description: 'The notification id',
})
@ApiResponse({
status: 200,
description: 'The notification has been successfully deleted.',
})
async deleteNotification(@Param('id', ParseIntPipe) notificationId: number) {
const user = await this.tenancyContext.getSystemUser();
const deleted = await this.createNotificationService.deleteNotification(
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Notification } from '../models/Notification.model';
export interface CreateNotificationPayload {
@@ -12,13 +13,18 @@ export interface CreateNotificationPayload {
@Injectable()
export class CreateNotificationService {
constructor(
@Inject(Notification.name)
private readonly notificationModel: TenantModelProxy<typeof Notification>,
) {}
/**
* Creates a new notification.
* @param {CreateNotificationPayload} payload - Notification data
* @returns {Promise<Notification>}
*/
async createNotification(payload: CreateNotificationPayload): Promise<Notification> {
const notification = await Notification.query().insert({
const notification = await this.notificationModel().query().insert({
userId: payload.userId ?? null,
title: payload.title,
message: payload.message,
@@ -38,7 +44,8 @@ export class CreateNotificationService {
* @returns {Promise<Notification | null>}
*/
async markAsRead(notificationId: number, userId: number): Promise<Notification | null> {
const notification = await Notification.query()
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;
}
}
@@ -1,4 +1,3 @@
import { Model } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class Notification extends TenantBaseModel {
@@ -92,27 +91,6 @@ export class Notification extends TenantBaseModel {
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { User } = require('@/modules/UsersModule/models/User.model');
return {
/**
* Notification belongs to a user.
*/
user: {
relation: Model.BelongsToOneRelation,
modelClass: User,
join: {
from: 'notifications.userId',
to: 'users.id',
},
},
};
}
/**
* JSON schema for validation.
*/
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Notification } from '../models/Notification.model';
interface GetNotificationsOptions {
@@ -10,6 +11,11 @@ interface GetNotificationsOptions {
@Injectable()
export class GetNotificationsService {
constructor(
@Inject(Notification.name)
private readonly notificationModel: TenantModelProxy<typeof Notification>,
) {}
/**
* Retrieves notifications for a user with pagination and filtering.
* @param {number} userId - The user ID
@@ -23,7 +29,8 @@ export class GetNotificationsService {
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();
+1
View File
@@ -59,6 +59,7 @@
"camelcase": "^5.3.1",
"classnames": "^2.3.2",
"cross-env": "^7.0.2",
"date-fns": "^4.1.0",
"deepdash": "^5.3.9",
"dependency-graph": "^0.11.0",
"dotenv-webpack": "^8.0.1",
+1
View File
@@ -51,6 +51,7 @@ export * from './generic-resource';
export * from './cashflow-accounts';
export * from './bank-rules';
export * from './misc';
export * from './notifications';
export * from './reports';
/**
+58
View File
@@ -0,0 +1,58 @@
import type { ApiFetcher } from './fetch-utils';
import { rawRequest } from './fetch-utils';
export const NOTIFICATIONS_ROUTES = {
LIST: '/api/notifications',
UNREAD_COUNT: '/api/notifications/unread-count',
MARK_AS_READ: '/api/notifications/{id}/read',
MARK_ALL_AS_READ: '/api/notifications/read-all',
DELETE: '/api/notifications/{id}/delete',
} as const;
export interface GetNotificationsQuery {
limit?: number;
offset?: number;
unreadOnly?: boolean;
category?: string;
}
export async function fetchNotifications(
fetcher: ApiFetcher,
query?: GetNotificationsQuery
): Promise<unknown> {
const params = new URLSearchParams();
if (query?.limit !== undefined) params.set('limit', String(query.limit));
if (query?.offset !== undefined) params.set('offset', String(query.offset));
if (query?.unreadOnly !== undefined) params.set('unreadOnly', String(query.unreadOnly));
if (query?.category) params.set('category', query.category);
const qs = params.toString();
const path = qs ? `${NOTIFICATIONS_ROUTES.LIST}?${qs}` : NOTIFICATIONS_ROUTES.LIST;
return rawRequest(fetcher, 'GET', path);
}
export async function fetchUnreadNotificationsCount(
fetcher: ApiFetcher
): Promise<unknown> {
return rawRequest(fetcher, 'GET', NOTIFICATIONS_ROUTES.UNREAD_COUNT);
}
export async function markNotificationAsRead(
fetcher: ApiFetcher,
id: number
): Promise<unknown> {
return rawRequest(fetcher, 'POST', `/api/notifications/${id}/read`);
}
export async function markAllNotificationsAsRead(
fetcher: ApiFetcher
): Promise<unknown> {
return rawRequest(fetcher, 'POST', NOTIFICATIONS_ROUTES.MARK_ALL_AS_READ);
}
export async function deleteNotification(
fetcher: ApiFetcher,
id: number
): Promise<unknown> {
return rawRequest(fetcher, 'POST', `/api/notifications/${id}/delete`);
}