From 6cd55454e831c6ceabd1e10e97d2700b8b558858 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 22 Feb 2026 13:03:00 +0330 Subject: [PATCH] announcments --- database/migrations/.snapshot-negareh.json | 222 ++++++++++++++++- .../migrations/Migration20260222092921.ts | 27 +++ src/app.module.ts | 2 + src/common/enums/message.enum.ts | 20 ++ src/common/enums/permission.enum.ts | 5 +- .../DTO/create-announcement.dto.ts | 49 ++++ .../DTO/search-announcement-query.dto.ts | 21 ++ .../DTO/update-announcement.dto.ts | 4 + .../announcements/announcement.controller.ts | 84 +++++++ .../announcements/announcement.module.ts | 28 +++ src/modules/announcements/constants/index.ts | 12 + .../entities/announcement.entity.ts | 27 +++ .../entities/user-announcement.entity.ts | 18 ++ .../interfaces/ISendAnnouncement.ts | 5 + .../providers/announcement.service.ts | 227 ++++++++++++++++++ .../queue/announcement.processor.ts | 84 +++++++ .../repositories/announcement.repository.ts | 10 + .../user-announcement.repository.ts | 10 + 18 files changed, 853 insertions(+), 2 deletions(-) create mode 100644 database/migrations/Migration20260222092921.ts create mode 100755 src/modules/announcements/DTO/create-announcement.dto.ts create mode 100755 src/modules/announcements/DTO/search-announcement-query.dto.ts create mode 100755 src/modules/announcements/DTO/update-announcement.dto.ts create mode 100755 src/modules/announcements/announcement.controller.ts create mode 100755 src/modules/announcements/announcement.module.ts create mode 100755 src/modules/announcements/constants/index.ts create mode 100755 src/modules/announcements/entities/announcement.entity.ts create mode 100755 src/modules/announcements/entities/user-announcement.entity.ts create mode 100755 src/modules/announcements/interfaces/ISendAnnouncement.ts create mode 100755 src/modules/announcements/providers/announcement.service.ts create mode 100644 src/modules/announcements/queue/announcement.processor.ts create mode 100755 src/modules/announcements/repositories/announcement.repository.ts create mode 100755 src/modules/announcements/repositories/user-announcement.repository.ts diff --git a/database/migrations/.snapshot-negareh.json b/database/migrations/.snapshot-negareh.json index a4b9d74..c2596e0 100644 --- a/database/migrations/.snapshot-negareh.json +++ b/database/migrations/.snapshot-negareh.json @@ -4,6 +4,118 @@ ], "name": "public", "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "char(26)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 26, + "mappedType": "character" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "title": { + "name": "title", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "content": { + "name": "content", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "publish_at": { + "name": "publish_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "is_important": { + "name": "is_important", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + } + }, + "name": "announcements", + "schema": "public", + "indexes": [ + { + "keyName": "announcements_is_public_created_at_index", + "columnNames": [ + "is_public", + "created_at" + ], + "composite": true, + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "announcements_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, { "columns": { "id": { @@ -728,7 +840,8 @@ "create_print", "view_print", "manage_learnings", - "manage_criticisms" + "manage_criticisms", + "manage_announcements" ], "mappedType": "enum" }, @@ -3474,6 +3587,113 @@ } }, "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "char(26)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 26, + "mappedType": "character" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "user_id": { + "name": "user_id", + "type": "char(26)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 26, + "mappedType": "character" + }, + "announcement_id": { + "name": "announcement_id", + "type": "char(26)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 26, + "mappedType": "character" + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + } + }, + "name": "user_announcements", + "schema": "public", + "indexes": [ + { + "keyName": "user_announcements_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "user_announcements_user_id_foreign": { + "constraintName": "user_announcements_user_id_foreign", + "columnNames": [ + "user_id" + ], + "localTableName": "public.user_announcements", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.users", + "updateRule": "cascade" + }, + "user_announcements_announcement_id_foreign": { + "constraintName": "user_announcements_announcement_id_foreign", + "columnNames": [ + "announcement_id" + ], + "localTableName": "public.user_announcements", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.announcements", + "updateRule": "cascade" + } + }, + "nativeEnums": {} } ], "nativeEnums": {} diff --git a/database/migrations/Migration20260222092921.ts b/database/migrations/Migration20260222092921.ts new file mode 100644 index 0000000..ab41a86 --- /dev/null +++ b/database/migrations/Migration20260222092921.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260222092921 extends Migration { + + override async up(): Promise { + this.addSql(`create table "announcements" ("id" char(26) not null, "created_at" timestamptz not null default now(), "deleted_at" timestamptz null, "title" varchar(100) not null, "content" text not null, "publish_at" timestamptz null, "is_important" boolean not null default false, "is_public" boolean not null default false, constraint "announcements_pkey" primary key ("id"));`); + this.addSql(`create index "announcements_is_public_created_at_index" on "announcements" ("is_public", "created_at");`); + + this.addSql(`create table "user_announcements" ("id" char(26) not null, "created_at" timestamptz not null default now(), "deleted_at" timestamptz null, "user_id" char(26) not null, "announcement_id" char(26) not null, "is_read" boolean not null default false, constraint "user_announcements_pkey" primary key ("id"));`); + + this.addSql(`alter table "user_announcements" add constraint "user_announcements_user_id_foreign" foreign key ("user_id") references "users" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table "user_announcements" add constraint "user_announcements_announcement_id_foreign" foreign key ("announcement_id") references "announcements" ("id") on update cascade on delete cascade;`); + + this.addSql(`alter table "permissions" drop constraint if exists "permissions_name_check";`); + + this.addSql(`alter table "permissions" add constraint "permissions_name_check" check("name" in ('manage_products', 'manage_categories', 'view_requests', 'delete_request', 'update_request', 'view_invoices', 'create_invoice', 'update_invoice', 'delete_invoice', 'view_all_invoiced_orders', 'view_my_assigned_invoiced_requests', 'view_orders', 'view_assigned_orders', 'create_order', 'update_order', 'assign_designer', 'delete_order', 'manage_admins', 'manage_roles', 'manage_users', 'manage_payments', 'manage_settings', 'manage_form_builder', 'create_print', 'view_print', 'manage_learnings', 'manage_criticisms', 'manage_announcements'));`); + } + + override async down(): Promise { + this.addSql(`alter table "permissions" drop constraint if exists "permissions_name_check";`); + this.addSql(`alter table "permissions" add constraint "permissions_name_check" check("name" in ('manage_products', 'manage_categories', 'view_requests', 'delete_request', 'update_request', 'view_invoices', 'create_invoice', 'update_invoice', 'delete_invoice', 'view_all_invoiced_orders', 'view_my_assigned_invoiced_requests', 'view_orders', 'view_assigned_orders', 'create_order', 'update_order', 'assign_designer', 'delete_order', 'manage_admins', 'manage_roles', 'manage_users', 'manage_payments', 'manage_settings', 'manage_form_builder', 'create_print', 'view_print', 'manage_learnings', 'manage_criticisms'));`); + + this.addSql(`drop table if exists "user_announcements";`); + this.addSql(`drop table if exists "announcements";`); + } + +} diff --git a/src/app.module.ts b/src/app.module.ts index 109f55b..b16a1c9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { InvoiceModule } from './modules/invoice/invoice.module'; import { OrderModule } from './modules/order/order.module'; import { LearningModule } from './modules/learnings/learning.module'; import { CriticismModule } from './modules/criticisms/criticisms.module'; +import { AnnouncementModule } from './modules/announcements/announcement.module'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { CriticismModule } from './modules/criticisms/criticisms.module'; OrderModule, LearningModule, CriticismModule, + AnnouncementModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5c41d17..cf6151e 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -137,6 +137,26 @@ export const enum LearningMessage { VIDEO_DURATION_INVALID = 'مدت زمان ویدیو نامعتبر است', } +export const enum AnnouncementMessage { + NOT_FOUND = 'اطلاعیه مورد نظر یافت نشد', + TITLE_IS_REQUIRED = 'عنوان الزامی است', + TITLE_STRING = 'عنوان باید رشته باشد', + TITLE_LENGTH = 'عنوان باید بین ۵ تا ۱۰۰ کاراکتر باشد', + CONTENT_IS_REQUIRED = 'محتوا الزامی است', + CONTENT_IS_STRING = 'محتوا باید رشته باشد', + CONTENT_MUST_BE_LONGER = 'محتوا باید حداقل ۱۰ کاراکتر باشد', + PUBLISH_AT_IS_REQUIRED = 'تاریخ انتشار الزامی است', + PUBLISH_AT_MUST_BE_DATE = 'تاریخ انتشار باید معتبر باشد', + PUBLISH_AT_MUST_BE_FUTURE_DATE = 'تاریخ انتشار باید در آینده باشد', + IMPORTANT_IS_REQUIRED = 'وضعیت اهمیت الزامی است', + IMPORTANT_MUST_BE_BOOLEAN = 'وضعیت اهمیت باید true یا false باشد', + USER_IDS_MUST_BE_ARRAY = 'لیست کاربران باید آرایه باشد', + USER_MUST_BE_UUID = 'شناسه کاربر باید معتبر باشد', + CREATED_AND_SENT_TO_USERS_AFTER_PUBLISH = 'اطلاعیه ایجاد شد و پس از انتشار به کاربران ارسال خواهد شد', + UPDATED_SUCCESSFULLY = 'اطلاعیه با موفقیت به‌روزرسانی شد', + DELETED_SUCCESSFULLY = 'اطلاعیه با موفقیت حذف شد', +} + export const enum CriticismMessage { NOT_FOUND = 'انتقاد مورد نظر یافت نشد', TITLE_IS_REQUIRED = 'عنوان الزامی است', diff --git a/src/common/enums/permission.enum.ts b/src/common/enums/permission.enum.ts index 718b8be..d3ccff8 100644 --- a/src/common/enums/permission.enum.ts +++ b/src/common/enums/permission.enum.ts @@ -45,6 +45,8 @@ export enum PermissionEnum { MANAGE_LEARNINGS = 'manage_learnings', MANAGE_CRITICISMS = 'manage_criticisms', + + MANAGE_ANNOUNCEMENTS = 'manage_announcements', } /** @@ -80,5 +82,6 @@ export const PermissionTitles: Record = { [PermissionEnum.CREATE_PRINT]: "ایجاد فرم چاپ برای سفارش", [PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ", [PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها", - [PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها" + [PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها", + [PermissionEnum.MANAGE_ANNOUNCEMENTS]: "مدیریت اطلاعیه‌ها" }; diff --git a/src/modules/announcements/DTO/create-announcement.dto.ts b/src/modules/announcements/DTO/create-announcement.dto.ts new file mode 100755 index 0000000..aba8a6c --- /dev/null +++ b/src/modules/announcements/DTO/create-announcement.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + IsNotEmpty, + IsOptional, + IsString, + Length, + MinLength, +} from 'class-validator'; + +import { AnnouncementMessage } from '../../../common/enums/message.enum'; + +export class CreateAnnouncementDto { + @IsNotEmpty({ message: AnnouncementMessage.TITLE_IS_REQUIRED }) + @IsString({ message: AnnouncementMessage.TITLE_STRING }) + @Length(5, 100, { message: AnnouncementMessage.TITLE_LENGTH }) + @ApiProperty({ description: 'Title of the announcement', example: 'اطلاعیه جدید' }) + title!: string; + + @IsNotEmpty({ message: AnnouncementMessage.CONTENT_IS_REQUIRED }) + @IsString({ message: AnnouncementMessage.CONTENT_IS_STRING }) + @MinLength(10, { message: AnnouncementMessage.CONTENT_MUST_BE_LONGER }) + @ApiProperty({ description: 'Content of the announcement', example: 'اطلاعیه جدید برای کاربران' }) + content!: string; + + @IsOptional() + @IsDateString({}, { message: AnnouncementMessage.PUBLISH_AT_MUST_BE_DATE }) + @ApiProperty({ description: 'Publish date of the announcement', example: '2025-02-25T10:00:00Z' }) + publishAt?: string; + + @IsOptional() + @IsBoolean({ message: AnnouncementMessage.IMPORTANT_MUST_BE_BOOLEAN }) + @ApiProperty({ description: 'Is this announcement important?', example: false }) + isImportant?: boolean; + + @IsOptional() + @ArrayMinSize(1) + @IsArray({ message: AnnouncementMessage.USER_IDS_MUST_BE_ARRAY }) + @IsString({ each: true, message: AnnouncementMessage.USER_MUST_BE_UUID }) + @ApiProperty({ + type: [String], + description: 'Array of user ids to send announcement to (if empty, sends to all users)', + example: [], + }) + userIds?: string[]; +} diff --git a/src/modules/announcements/DTO/search-announcement-query.dto.ts b/src/modules/announcements/DTO/search-announcement-query.dto.ts new file mode 100755 index 0000000..902d6fc --- /dev/null +++ b/src/modules/announcements/DTO/search-announcement-query.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; +import { PaginationDto } from '../../../common/DTO/pagination.dto'; +import { CriticismMessage } from '../../../common/enums/message.enum'; + +export class SearchAnnouncementQueryDto extends PaginationDto { + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: 'since query', example: '2025-01-27' }) + since?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: 'publishAt query', example: '2025-01-27' }) + publishAt?: string; + + @IsOptional() + @IsString({ message: CriticismMessage.SEARCH_QUERY_STRING }) + @ApiPropertyOptional({ description: 'Search query', example: 'search query' }) + q?: string; +} diff --git a/src/modules/announcements/DTO/update-announcement.dto.ts b/src/modules/announcements/DTO/update-announcement.dto.ts new file mode 100755 index 0000000..421a7fc --- /dev/null +++ b/src/modules/announcements/DTO/update-announcement.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateAnnouncementDto } from './create-announcement.dto'; + +export class UpdateAnnouncementDto extends PartialType(CreateAnnouncementDto) {} diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts new file mode 100755 index 0000000..a0cd8e1 --- /dev/null +++ b/src/modules/announcements/announcement.controller.ts @@ -0,0 +1,84 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateAnnouncementDto } from './DTO/create-announcement.dto'; +import { SearchAnnouncementQueryDto } from './DTO/search-announcement-query.dto'; +import { UpdateAnnouncementDto } from './DTO/update-announcement.dto'; +import { AnnouncementService } from './providers/announcement.service'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { AdminAuthGuard } from '../auth/guards/adminAuth.guard'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { UserId } from '../../common/decorators'; +import { PermissionEnum } from '../../common/enums/permission.enum'; + +@ApiTags('Announcements') +@ApiBearerAuth() +@Controller() +export class AnnouncementController { + constructor(private readonly announcementService: AnnouncementService) {} + + + @Get('/public/announcements/user') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get announcements for current user' }) + getAnnouncementsByUser(@Query() queryDto: SearchAnnouncementQueryDto, @UserId() userId: string) { + return this.announcementService.getAnnouncementsByUser(queryDto, userId); + } + + @Get('/public/announcements/user/count') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get unread announcement count for user' }) + countUserAnnouncements(@UserId() userId: string) { + return this.announcementService.countUserAnnouncements(userId); + } + + @Get('/public/announcements/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get one announcement by id (user)' }) + getAnnouncementById(@Param('id') id: string, @UserId() userId: string) { + return this.announcementService.getAnnouncementById(id, userId); + } + + //------------------ Admin Routes ----------------------------- + + @Post('/admin/announcements') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_ANNOUNCEMENTS) + @ApiOperation({ summary: 'Create a new announcement (admin)' }) + create(@Body() createAnnouncementDto: CreateAnnouncementDto) { + return this.announcementService.createAnnouncement(createAnnouncementDto); + } + + @Patch('/admin/announcements/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_ANNOUNCEMENTS) + @ApiOperation({ summary: 'Update an announcement (admin)' }) + update(@Param('id') id: string, @Body() updateAnnouncementDto: UpdateAnnouncementDto) { + return this.announcementService.updateAnnouncement(id, updateAnnouncementDto); + } + + @Delete('/admin/announcements:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_ANNOUNCEMENTS) + @ApiOperation({ summary: 'Delete an announcement (admin)' }) + delete(@Param('id') id: string) { + return this.announcementService.deleteAnnouncement(id); + } + + @Get('/admin/announcements') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_ANNOUNCEMENTS) + @ApiOperation({ summary: 'Get all announcements (admin)' }) + getAnnouncements(@Query() queryDto: SearchAnnouncementQueryDto) { + return this.announcementService.getAnnouncements(queryDto); + } + + + @Get('/admin/announcements/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_ANNOUNCEMENTS) + @ApiOperation({ summary: 'Get one announcement by id (admin)' }) + getAnnouncementByIdAdmin(@Param('id') id: string) { + return this.announcementService.getAnnouncementByIdAdmin(id); + } +} diff --git a/src/modules/announcements/announcement.module.ts b/src/modules/announcements/announcement.module.ts new file mode 100755 index 0000000..802a88c --- /dev/null +++ b/src/modules/announcements/announcement.module.ts @@ -0,0 +1,28 @@ +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { ConfigService } from '@nestjs/config'; + +import { AnnouncementController } from './announcement.controller'; +import { ANNOUNCEMENT } from './constants'; +import { Announcement } from './entities/announcement.entity'; +import { UserAnnouncement } from './entities/user-announcement.entity'; +import { AnnouncementService } from './providers/announcement.service'; +import { AnnouncementProcessor } from './queue/announcement.processor'; +import { AnnouncementRepository } from './repositories/announcement.repository'; +import { UserAnnouncementRepository } from './repositories/user-announcement.repository'; +import { UserModule } from '../user/user.module'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Announcement, UserAnnouncement]), + BullModule.registerQueue({ name: ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME }), + UserModule, + JwtModule, + ], + providers: [AnnouncementService, AnnouncementRepository, UserAnnouncementRepository, AnnouncementProcessor], + controllers: [AnnouncementController], + exports: [AnnouncementService], +}) +export class AnnouncementModule {} diff --git a/src/modules/announcements/constants/index.ts b/src/modules/announcements/constants/index.ts new file mode 100755 index 0000000..e415fe7 --- /dev/null +++ b/src/modules/announcements/constants/index.ts @@ -0,0 +1,12 @@ +export const ANNOUNCEMENT = Object.freeze({ + ANNOUNCEMENT_QUEUE_NAME: "announcement", + ANNOUNCEMENT_SEND_JOB_NAME: "sendAnnouncement", + ANNOUNCEMENT_PUBLISH_JOB_NAME: "publishAnnouncement", + // + ANNOUNCEMENT_SEND_JOB_PRIORITY: 1, + ANNOUNCEMENT_SEND_JOB_ATTEMPTS: 3, + ANNOUNCEMENT_SEND_JOB_BACKOFF: 5 * 1000, + ANNOUNCEMENT_SEND_JOB_TIMEOUT: 10000, + // + ANNOUNCEMENT_PUBLISH_JOB_PRIORITY: 1, +}); diff --git a/src/modules/announcements/entities/announcement.entity.ts b/src/modules/announcements/entities/announcement.entity.ts new file mode 100755 index 0000000..d165ae4 --- /dev/null +++ b/src/modules/announcements/entities/announcement.entity.ts @@ -0,0 +1,27 @@ +import { Cascade, Collection, Entity, Index, OneToMany, OptionalProps, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { UserAnnouncement } from './user-announcement.entity'; + +@Entity({ tableName: 'announcements' }) +@Index({ properties: ['isPublic', 'createdAt'] }) +export class Announcement extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt'; + + @Property({ type: 'varchar', length: 100 }) + title!: string; + + @Property({ type: 'text' }) + content!: string; + + @Property({ type: 'timestamptz', nullable: true }) + publishAt?: Date | null; + + @Property({ default: false }) + isImportant: boolean = false; + + @Property({ default: false }) + isPublic: boolean = false; + + @OneToMany(() => UserAnnouncement, (ua) => ua.announcement, { cascade: [Cascade.ALL] }) + userAnnouncements = new Collection(this); +} diff --git a/src/modules/announcements/entities/user-announcement.entity.ts b/src/modules/announcements/entities/user-announcement.entity.ts new file mode 100755 index 0000000..20715ad --- /dev/null +++ b/src/modules/announcements/entities/user-announcement.entity.ts @@ -0,0 +1,18 @@ +import { Entity, ManyToOne, OptionalProps, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { User } from '../../user/entities/user.entity'; +import { Announcement } from './announcement.entity'; + +@Entity({ tableName: 'user_announcements' }) +export class UserAnnouncement extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt'; + + @ManyToOne(() => User) + user!: User; + + @ManyToOne(() => Announcement) + announcement!: Announcement; + + @Property({ default: false }) + isRead: boolean = false; +} diff --git a/src/modules/announcements/interfaces/ISendAnnouncement.ts b/src/modules/announcements/interfaces/ISendAnnouncement.ts new file mode 100755 index 0000000..5d012d6 --- /dev/null +++ b/src/modules/announcements/interfaces/ISendAnnouncement.ts @@ -0,0 +1,5 @@ +export interface ISendAnnouncement { + announcementId: string; + isPublic: boolean; + userIds?: string[]; +} diff --git a/src/modules/announcements/providers/announcement.service.ts b/src/modules/announcements/providers/announcement.service.ts new file mode 100755 index 0000000..0989a8e --- /dev/null +++ b/src/modules/announcements/providers/announcement.service.ts @@ -0,0 +1,227 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Queue } from 'bullmq'; +import dayjs from 'dayjs'; + +import { AnnouncementMessage } from '../../../common/enums/message.enum'; +import { ANNOUNCEMENT } from '../constants'; +import { CreateAnnouncementDto } from '../DTO/create-announcement.dto'; +import { SearchAnnouncementQueryDto } from '../DTO/search-announcement-query.dto'; +import { UpdateAnnouncementDto } from '../DTO/update-announcement.dto'; +import { Announcement } from '../entities/announcement.entity'; +import { UserAnnouncement } from '../entities/user-announcement.entity'; +import { AnnouncementRepository } from '../repositories/announcement.repository'; +import { UserAnnouncementRepository } from '../repositories/user-announcement.repository'; + +@Injectable() +export class AnnouncementService { + constructor( + @InjectQueue(ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME) private readonly announcementQueue: Queue, + private readonly announcementRepository: AnnouncementRepository, + private readonly userAnnouncementRepository: UserAnnouncementRepository, + private readonly em: EntityManager, + ) {} + + async createAnnouncement(createAnnouncementDto: CreateAnnouncementDto) { + const { userIds } = createAnnouncementDto; + + let isPublic = true; + const publishAt = createAnnouncementDto.publishAt ? dayjs(createAnnouncementDto.publishAt).toDate() : dayjs().toDate(); + + if (dayjs(publishAt).isBefore(dayjs().subtract(1, 'day'))) { + throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE); + } + + if (userIds && userIds.length > 0) { + isPublic = false; + } + + const announcement = this.em.create(Announcement, { + title: createAnnouncementDto.title, + content: createAnnouncementDto.content, + publishAt, + isImportant: createAnnouncementDto.isImportant ?? false, + isPublic, + }); + + await this.em.persistAndFlush(announcement); + + await this.announcementQueue.add( + ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME, + { + announcementId: announcement.id, + isPublic, + userIds: userIds ?? [], + }, + { + delay: dayjs(publishAt).diff(dayjs(), 'millisecond'), + attempts: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_ATTEMPTS, + backoff: { type: 'exponential', delay: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_BACKOFF }, + }, + ); + + return { + message: AnnouncementMessage.CREATED_AND_SENT_TO_USERS_AFTER_PUBLISH, + announcement, + }; + } + + async getAnnouncements(queryDto: SearchAnnouncementQueryDto) { + const page = queryDto.page ?? 1; + const limit = queryDto.limit ?? 10; + const offset = (page - 1) * limit; + + const where: Record = {}; + + if (queryDto.since) { + where.createdAt = { $gte: new Date(queryDto.since) }; + } + + if (queryDto.publishAt) { + where.publishAt = { $gte: new Date(queryDto.publishAt) }; + } + + if (queryDto.q) { + where.title = { $like: `%${queryDto.q}%` }; + } + + const [announcements, count] = await this.em.findAndCount(Announcement, where, { + orderBy: { createdAt: 'DESC' }, + offset, + limit, + }); + + return { announcements, count, paginate: true }; + } + + async getAnnouncementsByUser(queryDto: SearchAnnouncementQueryDto, userId: string) { + const page = queryDto.page ?? 1; + const limit = queryDto.limit ?? 10; + const offset = (page - 1) * limit; + + const announcementFilter: Record = {}; + if (queryDto.since) { + announcementFilter.createdAt = { $gte: new Date(queryDto.since) }; + } + if (queryDto.publishAt) { + announcementFilter.publishAt = { $gte: new Date(queryDto.publishAt) }; + } + if (queryDto.q) { + announcementFilter.title = { $like: `%${queryDto.q}%` }; + } + + const where = { + user: { id: userId }, + ...(Object.keys(announcementFilter).length > 0 && { announcement: announcementFilter }), + }; + + const [userAnnouncements, count] = await this.em.findAndCount(UserAnnouncement, where, { + orderBy: { announcement: { createdAt: 'DESC' } }, + offset, + limit, + populate: ['announcement'], + }); + + return { announcements: userAnnouncements, count, paginate: true }; + } + + async getAnnouncementById(id: string, userId: string) { + const announcement = await this.announcementRepository.findOne({ id }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + const userAnnouncement = await this.userAnnouncementRepository.findOne({ + announcement: { id }, + user: { id: userId }, + }); + if (!userAnnouncement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + userAnnouncement.isRead = true; + await this.em.flush(); + + return { announcement }; + } + + async getAnnouncementByIdAdmin(id: string) { + const announcement = await this.announcementRepository.findOne({ id }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + return { + announcement, + }; + } + + async countUserAnnouncements(userId: string) { + return this.userAnnouncementRepository.count({ + user: { id: userId }, + isRead: false, + }); + } + + async updateAnnouncement(id: string, updateAnnouncementDto: UpdateAnnouncementDto) { + const announcement = await this.announcementRepository.findOne({ id }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + const { userIds } = updateAnnouncementDto; + let isPublic = true; + + if (userIds && userIds.length > 0) { + isPublic = false; + } + + if (updateAnnouncementDto.publishAt) { + if (dayjs(updateAnnouncementDto.publishAt).isBefore(dayjs().subtract(1, 'day'))) { + throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE); + } + } + + const { userIds: _userIds, ...updateFields } = updateAnnouncementDto; + this.em.assign(announcement, { + ...updateFields, + publishAt: updateAnnouncementDto.publishAt ? dayjs(updateAnnouncementDto.publishAt).toDate() : undefined, + isImportant: updateAnnouncementDto.isImportant ?? announcement.isImportant, + }); + await this.em.flush(); + + if (updateAnnouncementDto.publishAt) { + const jobs = await this.announcementQueue.getJobs(['delayed', 'waiting', 'active']); + const existingJob = jobs.find((job) => job.data?.announcementId === id); + if (existingJob) await existingJob.remove(); + + await this.announcementQueue.add( + ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME, + { + announcementId: announcement.id, + isPublic, + userIds: userIds ?? [], + }, + { + delay: dayjs(announcement.publishAt ?? new Date()).diff(dayjs(), 'millisecond'), + attempts: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_ATTEMPTS, + backoff: { type: 'exponential', delay: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_BACKOFF }, + }, + ); + } + + return { + message: AnnouncementMessage.UPDATED_SUCCESSFULLY, + announcement, + }; + } + + async deleteAnnouncement(id: string) { + const announcement = await this.announcementRepository.findOne({ id }); + if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); + + const jobs = await this.announcementQueue.getJobs(['delayed', 'waiting', 'active']); + const existingJob = jobs.find((job) => job.data?.announcementId === id); + if (existingJob) await existingJob.remove(); + + await this.em.nativeDelete(UserAnnouncement, { announcement: { id } }); + await this.em.removeAndFlush(announcement); + + return { + message: AnnouncementMessage.DELETED_SUCCESSFULLY, + }; + } +} diff --git a/src/modules/announcements/queue/announcement.processor.ts b/src/modules/announcements/queue/announcement.processor.ts new file mode 100644 index 0000000..834d5ab --- /dev/null +++ b/src/modules/announcements/queue/announcement.processor.ts @@ -0,0 +1,84 @@ +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { EntityManager } from '@mikro-orm/postgresql'; + +import { User } from '../../user/entities/user.entity'; +import { ANNOUNCEMENT } from '../constants'; +import { Announcement } from '../entities/announcement.entity'; +import { UserAnnouncement } from '../entities/user-announcement.entity'; +import { ISendAnnouncement } from '../interfaces/ISendAnnouncement'; + +@Processor(ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME) +export class AnnouncementProcessor extends WorkerHost { + protected readonly logger = new Logger(AnnouncementProcessor.name); + + constructor(private readonly em: EntityManager) { + super(); + } + + async process(job: Job): Promise { + this.logger.log(`Processing job: ${job.name}, id: ${job.id}`); + + switch (job.name) { + case ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME: + return await this.sendAnnouncement(job); + case ANNOUNCEMENT.ANNOUNCEMENT_PUBLISH_JOB_NAME: + return this.publishAnnouncement(job); + default: + this.logger.error(`Unknown job name: ${job.name}`); + return; + } + } + + private async sendAnnouncement(job: Job): Promise { + this.logger.log(`Sending announcement: ${job.data.announcementId} to users.`); + + const data = job.data; + + await this.em.transactional(async (em) => { + let userIds: string[] = []; + + if (data.isPublic) { + const users = await em.find(User, {}, { fields: ['id'] }); + userIds = users.map((u) => u.id); + } else if (data.userIds && data.userIds.length > 0) { + userIds = data.userIds; + } + + if (userIds.length === 0) { + this.logger.warn('No users found for announcement'); + return; + } + + const announcementId = data.announcementId; + this.logger.debug(`Sending announcement to ${userIds.length} users`); + + const userAnnouncements = userIds.map((userId) => + em.create(UserAnnouncement, { + user: em.getReference(User, userId), + announcement: em.getReference(Announcement, announcementId), + isRead: false, + }), + ); + + await em.persistAndFlush(userAnnouncements); + + this.logger.log(`Successfully sent announcement ${announcementId} to ${userIds.length} users`); + }); + } + + private publishAnnouncement(job: Job): void { + this.logger.log(`Publishing announcement: ${job.data.announcementId}`); + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.log(`Announcement job ${job.id} completed`); + } + + @OnWorkerEvent('failed') + onFailed(job: Job, error: Error) { + this.logger.error(`Announcement job ${job.id} failed:`, error); + } +} diff --git a/src/modules/announcements/repositories/announcement.repository.ts b/src/modules/announcements/repositories/announcement.repository.ts new file mode 100755 index 0000000..73e9ae7 --- /dev/null +++ b/src/modules/announcements/repositories/announcement.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { Announcement } from '../entities/announcement.entity'; + +@Injectable() +export class AnnouncementRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Announcement); + } +} diff --git a/src/modules/announcements/repositories/user-announcement.repository.ts b/src/modules/announcements/repositories/user-announcement.repository.ts new file mode 100755 index 0000000..73c0f81 --- /dev/null +++ b/src/modules/announcements/repositories/user-announcement.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { UserAnnouncement } from '../entities/user-announcement.entity'; + +@Injectable() +export class UserAnnouncementRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, UserAnnouncement); + } +}