diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index c1551f3a2..ffd7e1648 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -113,6 +113,7 @@ import { AppThrottleModule } from './AppThrottle.module'; rootPath: join(__dirname, '../../..', 'public'), serveRoot: '/public', }), + ConfigModule.forRoot({ envFilePath: '.env', load: config, diff --git a/packages/server/src/modules/Notifications/Notifications.controller.ts b/packages/server/src/modules/Notifications/Notifications.controller.ts index ab0ae9f82..ff2c93b5a 100644 --- a/packages/server/src/modules/Notifications/Notifications.controller.ts +++ b/packages/server/src/modules/Notifications/Notifications.controller.ts @@ -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( diff --git a/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts b/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts index 3bc4a8d0e..cae5e8b7c 100644 --- a/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts +++ b/packages/server/src/modules/Notifications/commands/CreateNotification.service.ts @@ -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, + ) {} + /** * Creates a new notification. * @param {CreateNotificationPayload} payload - Notification data * @returns {Promise} */ async createNotification(payload: CreateNotificationPayload): Promise { - 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} */ async markAsRead(notificationId: number, userId: number): Promise { - 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 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/models/Notification.model.ts b/packages/server/src/modules/Notifications/models/Notification.model.ts index ccdec57f6..c8a298bd1 100644 --- a/packages/server/src/modules/Notifications/models/Notification.model.ts +++ b/packages/server/src/modules/Notifications/models/Notification.model.ts @@ -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. */ diff --git a/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts b/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts index 695472ef8..28b29ce7b 100644 --- a/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts +++ b/packages/server/src/modules/Notifications/queries/GetNotifications.service.ts @@ -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, + ) {} + /** * 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} */ async getUnreadCount(userId: number): Promise { - return Notification.query() + return this.notificationModel() + .query() .modify('forUser', userId) .modify('unread') .resultSize(); diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 77653d103..fd5aa7181 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -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", diff --git a/shared/sdk-ts/src/index.ts b/shared/sdk-ts/src/index.ts index c7e7a006d..6652daff0 100644 --- a/shared/sdk-ts/src/index.ts +++ b/shared/sdk-ts/src/index.ts @@ -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'; /** diff --git a/shared/sdk-ts/src/notifications.ts b/shared/sdk-ts/src/notifications.ts new file mode 100644 index 000000000..252abc5c8 --- /dev/null +++ b/shared/sdk-ts/src/notifications.ts @@ -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 { + 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 { + return rawRequest(fetcher, 'GET', NOTIFICATIONS_ROUTES.UNREAD_COUNT); +} + +export async function markNotificationAsRead( + fetcher: ApiFetcher, + id: number +): Promise { + return rawRequest(fetcher, 'POST', `/api/notifications/${id}/read`); +} + +export async function markAllNotificationsAsRead( + fetcher: ApiFetcher +): Promise { + return rawRequest(fetcher, 'POST', NOTIFICATIONS_ROUTES.MARK_ALL_AS_READ); +} + +export async function deleteNotification( + fetcher: ApiFetcher, + id: number +): Promise { + return rawRequest(fetcher, 'POST', `/api/notifications/${id}/delete`); +}