announcments

This commit is contained in:
2026-02-22 13:03:00 +03:30
parent 2c4b4fdc56
commit 6cd55454e8
18 changed files with 853 additions and 2 deletions
+221 -1
View File
@@ -4,6 +4,118 @@
], ],
"name": "public", "name": "public",
"tables": [ "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": { "columns": {
"id": { "id": {
@@ -728,7 +840,8 @@
"create_print", "create_print",
"view_print", "view_print",
"manage_learnings", "manage_learnings",
"manage_criticisms" "manage_criticisms",
"manage_announcements"
], ],
"mappedType": "enum" "mappedType": "enum"
}, },
@@ -3474,6 +3587,113 @@
} }
}, },
"nativeEnums": {} "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": {} "nativeEnums": {}
@@ -0,0 +1,27 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260222092921 extends Migration {
override async up(): Promise<void> {
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<void> {
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";`);
}
}
+2
View File
@@ -22,6 +22,7 @@ import { InvoiceModule } from './modules/invoice/invoice.module';
import { OrderModule } from './modules/order/order.module'; import { OrderModule } from './modules/order/order.module';
import { LearningModule } from './modules/learnings/learning.module'; import { LearningModule } from './modules/learnings/learning.module';
import { CriticismModule } from './modules/criticisms/criticisms.module'; import { CriticismModule } from './modules/criticisms/criticisms.module';
import { AnnouncementModule } from './modules/announcements/announcement.module';
@Module({ @Module({
imports: [ imports: [
@@ -52,6 +53,7 @@ import { CriticismModule } from './modules/criticisms/criticisms.module';
OrderModule, OrderModule,
LearningModule, LearningModule,
CriticismModule, CriticismModule,
AnnouncementModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+20
View File
@@ -137,6 +137,26 @@ export const enum LearningMessage {
VIDEO_DURATION_INVALID = 'مدت زمان ویدیو نامعتبر است', 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 { export const enum CriticismMessage {
NOT_FOUND = 'انتقاد مورد نظر یافت نشد', NOT_FOUND = 'انتقاد مورد نظر یافت نشد',
TITLE_IS_REQUIRED = 'عنوان الزامی است', TITLE_IS_REQUIRED = 'عنوان الزامی است',
+4 -1
View File
@@ -45,6 +45,8 @@ export enum PermissionEnum {
MANAGE_LEARNINGS = 'manage_learnings', MANAGE_LEARNINGS = 'manage_learnings',
MANAGE_CRITICISMS = 'manage_criticisms', MANAGE_CRITICISMS = 'manage_criticisms',
MANAGE_ANNOUNCEMENTS = 'manage_announcements',
} }
/** /**
@@ -80,5 +82,6 @@ export const PermissionTitles: Record<PermissionEnum, string> = {
[PermissionEnum.CREATE_PRINT]: "ایجاد فرم چاپ برای سفارش", [PermissionEnum.CREATE_PRINT]: "ایجاد فرم چاپ برای سفارش",
[PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ", [PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ",
[PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها", [PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها",
[PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها" [PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها",
[PermissionEnum.MANAGE_ANNOUNCEMENTS]: "مدیریت اطلاعیه‌ها"
}; };
+49
View File
@@ -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[];
}
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateAnnouncementDto } from './create-announcement.dto';
export class UpdateAnnouncementDto extends PartialType(CreateAnnouncementDto) {}
+84
View File
@@ -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);
}
}
+28
View File
@@ -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 {}
+12
View File
@@ -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,
});
+27
View File
@@ -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<UserAnnouncement>(this);
}
@@ -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;
}
@@ -0,0 +1,5 @@
export interface ISendAnnouncement {
announcementId: string;
isPublic: boolean;
userIds?: string[];
}
+227
View File
@@ -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<string, unknown> = {};
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<string, unknown> = {};
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,
};
}
}
@@ -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<ISendAnnouncement>): Promise<unknown> {
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<ISendAnnouncement>): Promise<void> {
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);
}
}
@@ -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<Announcement> {
constructor(readonly em: EntityManager) {
super(em, Announcement);
}
}
@@ -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<UserAnnouncement> {
constructor(readonly em: EntityManager) {
super(em, UserAnnouncement);
}
}