chore: add announcement logic

This commit is contained in:
Mahyargdz
2025-05-18 14:31:53 +03:30
parent 9eeab529a9
commit 4e4abb5a9c
58 changed files with 1280 additions and 243 deletions
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/oCsqFA8hF9TMv4UX5E
IeDv1KXzI+KFIafZ+gU1IWgEmWP16ee8Vw1+j7Ixl9Dd7Uxc9DhcWRB6ZDwqCYb3
S3z5zabOIka7wNNBpTEmRKrmV/EzihAw+E96Ksct0PHeN2YFydPimMndBD6VoRWi
WShQD4FklIQKqBYrua0NkuIkFshYLzC3rAbcT8GkLkcbHKrKIlyeucCLwym2nd5U
coI+fKqDB1/3JYEKJRaZgFqthokeFBN4SV9tMaLzeJDk3bNoTAxRtPbtuJ8mVTL9
P0phzjorGxTxV7SbANtmB3Trm6oIXeCqY40Auy+ZTUEVr+hD4Xxs8TMvjuT8vrMe
VQIDAQAB
-----END PUBLIC KEY-----
+2
View File
@@ -14,6 +14,7 @@ import { mailerConfig } from "./configs/mailer.config";
import { databaseConfig } from "./configs/mikro-orm.config";
import { rateLimitConfig } from "./configs/rateLimit.config";
import { HTTPLogger } from "./core/middlewares/logger.middleware";
import { AnnouncementsModule } from "./modules/announcements/announcement.module";
import { AuthModule } from "./modules/auth/auth.module";
import { BusinessesModule } from "./modules/businesses/businesses.module";
import { CompaniesModule } from "./modules/companies/companies.module";
@@ -53,6 +54,7 @@ import { UtilsModule } from "./modules/utils/utils.module";
TicketsModule,
InvoicesModule,
BusinessesModule,
AnnouncementsModule,
],
// providers: [
// {
+3 -1
View File
@@ -3,4 +3,6 @@ export const AUTH_THROTTLE_TTL = 5 * 60 * 1000;
export const AUTH_THROTTLE_LIMIT = 5;
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
export const JWT_STRATEGY_NAME = "jwt_Strategy";
//
export const CONSOLE_JWT_STRATEGY_NAME = "console_jwt_strategy";
export const LOCAL_JWT_STRATEGY_NAME = "local_jwt_strategy";
+4
View File
@@ -236,6 +236,8 @@ export const enum AnnouncementMessage {
PUBLISH_AT_CANNOT_BE_IN_PAST = "تاریخ انتشار نمی‌تواند در گذشته باشد",
USER_ANNOUNCEMENTS_DELETED = "اطلاعیه‌های کاربر با موفقیت حذف شدند",
QUEUE_JOBS_DELETED = "وظایف صف با موفقیت حذف شدند",
COMPANY_MUST_BE_ID = "شناسه شرکت اجباری است",
COMPANY_MUST_BE_UUID = "شناسه شرکت باید یک UUID معتبر باشد",
}
export const enum CriticismMessage {
@@ -293,6 +295,8 @@ export const enum TicketMessageEnum {
INVALID_TICKET_LIMIT = "محدودیت تعداد تیکت در پلن پشتیبانی شما نامعتبر است",
TICKET_LIMIT_EXCEEDED = "شما به محدودیت تعداد تیکت در پلن پشتیبانی خود رسیده‌اید",
OPENED = "تیکت با موفقیت باز شد",
CATEGORY_HAS_TICKETS = "دسته بندی دارای تیکت می باشد",
CATEGORY_DELETED = "دسته بندی با موفقیت حذف شد",
}
export const enum WalletMessage {
+1 -1
View File
@@ -20,7 +20,7 @@ export function mailerConfig(): MailerAsyncOptions {
},
template: {
dir: process.cwd() + "/src/templates/email",
dir: process.cwd() + "/src/modules/templates/email",
adapter: new HandlebarsAdapter(),
options: {
strict: true,
+29
View File
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { 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()
@IsNotEmpty({ message: AnnouncementMessage.PUBLISH_AT_IS_REQUIRED })
@IsDateString({}, { message: AnnouncementMessage.PUBLISH_AT_MUST_BE_DATE })
@ApiProperty({ description: "Publish date of the announcement", example: "2023-10-01T10:00:00Z" })
publishAt: string;
@IsNotEmpty({ message: AnnouncementMessage.IMPORTANT_IS_REQUIRED })
@IsBoolean({ message: AnnouncementMessage.IMPORTANT_MUST_BE_BOOLEAN })
@ApiProperty({ description: "Is this announcement important?", example: false })
isImportant: boolean;
}
@@ -0,0 +1,22 @@
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;
}
+5
View File
@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateAnnouncementDto } from "./create-announcement.dto";
export class UpdateAnnouncementDto extends PartialType(CreateAnnouncementDto) {}
+66
View File
@@ -0,0 +1,66 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
import { ApiProperty } 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 { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
import { Business } from "../businesses/entities/business.entity";
@Controller("announcements")
@UseInterceptors(BusinessInterceptor)
@AuthGuards()
export class AnnouncementController {
constructor(private readonly announcementService: AnnouncementService) {}
@ApiProperty({ description: "Create a new announcement (admin route)" })
@Post()
create(@Body() createAnnouncementDto: CreateAnnouncementDto, @BusinessDec() business: Business) {
return this.announcementService.createAnnouncement(createAnnouncementDto, business);
}
@ApiProperty({ description: "Update an announcement (admin route)" })
@Patch(":id")
update(@Param() paramDto: ParamDto, @Body() updateAnnouncementDto: UpdateAnnouncementDto, @BusinessDec("id") businessId: string) {
return this.announcementService.updateAnnouncement(paramDto.id, updateAnnouncementDto, businessId);
}
@ApiProperty({ description: "Delete an announcement (admin route)" })
@Delete(":id")
delete(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.announcementService.deleteAnnouncement(paramDto.id, businessId);
}
@ApiProperty({ description: "Get all announcements (admin route)" })
@Get()
getAnnouncements(@Query() queryDto: SearchAnnouncementQueryDto, @BusinessDec("id") businessId: string) {
return this.announcementService.getAnnouncements(queryDto, businessId);
}
@ApiProperty({ description: "Get all announcements for user " })
@Get("user")
getAnnouncementsByUser(
@Query() queryDto: SearchAnnouncementQueryDto,
@UserDec("id") userId: string,
@BusinessDec("id") businessId: string,
) {
return this.announcementService.getAnnouncementsByUser(queryDto, userId, businessId);
}
@ApiProperty({ description: "Get one announcements with id " })
@Get(":id")
getAnnouncementById(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.announcementService.getAnnouncementById(paramDto.id, userId, businessId);
}
@ApiProperty({ description: "Get one announcements with id (admin route)" })
@Get(":id/admin")
getAnnouncementByIdAdmin(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.announcementService.getAnnouncementByIdAdmin(paramDto.id, businessId);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { AnnouncementController } from "./announcement.controller";
import { ANNOUNCEMENT } from "./constants";
import { Announcement } from "./entities/announcement.entity";
import { AnnouncementService } from "./providers/announcement.service";
import { AnnouncementProcessor } from "./queue/announcment.processor";
import { UsersModule } from "../users/users.module";
import { UserAnnouncement } from "./entities/user-announcement.entity";
import { BusinessesModule } from "../businesses/businesses.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationModule } from "../notifications/notifications.module";
@Module({
imports: [
MikroOrmModule.forFeature([Announcement, UserAnnouncement]),
BullModule.registerQueue({ name: ANNOUNCEMENT.ANNOUNCEMENT_QUEUE_NAME }),
UsersModule,
NotificationModule,
CompaniesModule,
BusinessesModule,
],
providers: [AnnouncementService, AnnouncementProcessor],
controllers: [AnnouncementController],
exports: [AnnouncementService],
})
export class AnnouncementsModule {}
+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,
});
+33
View File
@@ -0,0 +1,33 @@
import { Cascade, Collection, Entity, EntityRepositoryType, Index, ManyToOne, OneToMany, Property } from "@mikro-orm/core";
import { UserAnnouncement } from "./user-announcement.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { AnnouncementRepository } from "../repositories/announcement.repository";
@Entity({ repository: () => AnnouncementRepository })
@Index({ properties: ["isPublic", "createdAt"] })
export class Announcement extends BaseEntity {
@Property({ type: "varchar", length: 100 })
title!: string;
@Property({ type: "text" })
content!: string;
@Property({ type: "timestamptz", nullable: true, default: null })
publishAt?: Date;
@Property({ type: "boolean", default: false })
isImportant!: boolean;
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
business!: Business;
@OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.announcement, { cascade: [Cascade.ALL] })
userAnnouncements = new Collection<UserAnnouncement>(this);
@Property({ type: "boolean", default: false })
isPublic!: boolean;
[EntityRepositoryType]?: AnnouncementRepository;
}
@@ -0,0 +1,20 @@
import { Entity, EntityRepositoryType, ManyToOne, Opt, Property } from "@mikro-orm/core";
import { Announcement } from "./announcement.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
import { UserAnnouncementRepository } from "../repositories/user-announcement.repository";
@Entity({ repository: () => UserAnnouncementRepository })
export class UserAnnouncement extends BaseEntity {
@ManyToOne(() => User, { deleteRule: "cascade", nullable: false })
user!: User;
@ManyToOne(() => Announcement, { deleteRule: "cascade", nullable: false })
announcement!: Announcement;
@Property({ type: "boolean", default: false })
isRead!: boolean & Opt;
[EntityRepositoryType]?: UserAnnouncementRepository;
}
@@ -0,0 +1,4 @@
export interface ISendAnnouncement {
announcementId: string;
isPublic: boolean;
}
+235
View File
@@ -0,0 +1,235 @@
import { EntityManager, FilterQuery } from "@mikro-orm/postgresql";
import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Queue } from "bullmq";
import dayjs from "dayjs";
import { AnnouncementMessage } from "../../../common/enums/message.enum";
import { Business } from "../../businesses/entities/business.entity";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
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, business: Business) {
const isPublic = true;
const announcement = this.announcementRepository.create({
...createAnnouncementDto,
business,
isPublic: true,
});
if (!announcement.publishAt) {
announcement.publishAt = dayjs().toDate();
}
if (dayjs(announcement.publishAt).isBefore(dayjs().subtract(1, "day")))
throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE);
await this.em.persistAndFlush(announcement);
await this.announcementQueue.add(
ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME,
{
announcementId: announcement.id,
isPublic,
},
{
delay: dayjs(announcement.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, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const queryBuilder = this.announcementRepository.createQueryBuilder("announcement");
queryBuilder.andWhere({ business: { id: businessId } });
if (queryDto.since) {
queryBuilder.andWhere({ createdAt: { $gte: dayjs(queryDto.since).toDate() } });
}
if (queryDto.publishAt) {
queryBuilder.andWhere({ publishAt: { $gte: dayjs(queryDto.publishAt).toDate() } });
}
if (queryDto.q) {
queryBuilder.andWhere({ title: { $ilike: `%${queryDto.q}%` } });
}
queryBuilder.orderBy({ createdAt: "DESC" });
const [announcements, count] = await queryBuilder.offset(skip).limit(limit).getResultAndCount();
return { announcements, count, paginate: true };
}
/***************************************** */
async getAnnouncementsByUser(queryDto: SearchAnnouncementQueryDto, userId: string, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<UserAnnouncement> = {
user: { id: userId },
};
const announcementConditions: FilterQuery<Announcement> = {
business: { id: businessId },
};
if (queryDto.since) {
announcementConditions.createdAt = { $gte: dayjs(queryDto.since).toDate() };
}
if (queryDto.publishAt) {
announcementConditions.publishAt = { $gte: dayjs(queryDto.publishAt).toDate() };
}
if (queryDto.q) {
announcementConditions.title = { $ilike: `%${queryDto.q}%` };
}
where.announcement = announcementConditions;
const [announcements, count] = await this.userAnnouncementRepository.findAndCount(where, {
populate: ["announcement", "user"],
limit,
offset: skip,
orderBy: { createdAt: "DESC" },
fields: [
"id",
"isRead",
"createdAt",
"user.id",
"user.firstName",
"user.lastName",
"announcement.id",
"announcement.title",
"announcement.isImportant",
"announcement.createdAt",
"announcement.publishAt",
"announcement.content",
],
});
return { announcements, count, paginate: true };
}
/***************************************** */
async getAnnouncementById(id: string, userId: string, businessId: string) {
const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } });
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, businessId: string) {
const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } });
if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND);
//
return {
announcement,
};
}
//************************************ */
async countUserAnnouncements(userId: string, businessId: string) {
return this.userAnnouncementRepository.count({ user: { id: userId }, announcement: { business: { id: businessId } }, isRead: false });
}
//************************************ */
async updateAnnouncement(id: string, updateAnnouncementDto: UpdateAnnouncementDto, businessId: string) {
const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } });
if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND);
if (updateAnnouncementDto.publishAt) {
if (dayjs(updateAnnouncementDto.publishAt).isBefore(dayjs().subtract(1, "day"))) {
throw new BadRequestException(AnnouncementMessage.PUBLISH_AT_MUST_BE_FUTURE_DATE);
}
}
if (updateAnnouncementDto.publishAt) {
const jobs = await this.announcementQueue.getJobs();
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: announcement.isPublic,
},
{
delay: dayjs(announcement.publishAt).diff(dayjs(), "millisecond"),
attempts: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_ATTEMPTS,
backoff: { type: "exponential", delay: ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_BACKOFF },
},
);
}
this.announcementRepository.assign(announcement, updateAnnouncementDto);
await this.em.flush();
return {
message: AnnouncementMessage.UPDATED_SUCCESSFULLY,
announcement,
};
}
//************************************ */
async deleteAnnouncement(id: string, businessId: string) {
const announcement = await this.announcementRepository.findOne({ id, business: { id: businessId } });
if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND);
const jobs = await this.announcementQueue.getJobs();
const existingJob = jobs.find((job) => job.data.announcementId === id);
if (existingJob) await existingJob.remove();
await this.userAnnouncementRepository.nativeDelete({ announcement: { id } });
await this.announcementRepository.nativeDelete({ id, business: { id: businessId } });
await this.em.flush();
return {
message: AnnouncementMessage.DELETED_SUCCESSFULLY,
};
}
}
+107
View File
@@ -0,0 +1,107 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { IAnnouncementNotificationData } from "../../notifications/interfaces/ISendNotificationData";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
import { UsersService } from "../../users/services/users.service";
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 WorkerProcessor {
protected readonly logger = new Logger(AnnouncementProcessor.name);
constructor(
private readonly em: EntityManager,
private readonly notificationQueue: NotificationQueue,
private readonly usersService: UsersService,
) {
super();
}
async process(job: Job, token?: string) {
switch (job.name) {
case ANNOUNCEMENT.ANNOUNCEMENT_SEND_JOB_NAME:
return await this.sendAnnouncement(job, token);
case ANNOUNCEMENT.ANNOUNCEMENT_PUBLISH_JOB_NAME:
return this.publishAnnouncement(job, token);
default:
this.logger.error(`Unknown job name: ${job.name}`);
return;
}
}
//********************************** */
private async sendAnnouncement(job: Job<ISendAnnouncement>, token?: string) {
this.logger.log(`Sending announcement: ${job.data.announcementId} to users. ${token}`);
const em = this.em.fork();
const data = job.data;
try {
await em.begin();
// Create a query builder to fetch unique users
const userQueryBuilder = em.createQueryBuilder(User, "u");
userQueryBuilder.select("u.id").distinct();
// Execute the query to get distinct user IDs
const userResult = await userQueryBuilder.execute();
const userIds = userResult.map((row: { id: string }) => row.id);
if (userIds.length === 0) {
this.logger.error(`No users found`);
await em.rollback();
return;
}
const announcementId = data.announcementId;
this.logger.debug(`Sending announcement to ${userIds.length} users`);
// Create UserAnnouncement entities
const userAnnouncements = userIds.map((userId: string) => {
const userAnnouncement = new UserAnnouncement();
userAnnouncement.user = em.getReference(User, userId);
userAnnouncement.announcement = em.getReference(Announcement, announcementId);
userAnnouncement.isRead = false;
return userAnnouncement;
});
await em.persistAndFlush(userAnnouncements);
// Fetch announcement details for notification
const announcement = await em.findOne(Announcement, { id: job.data.announcementId });
if (!announcement) throw new Error("Announcement not found");
// Send notification to each user
for (const userId of userIds) {
const user = await this.usersService.findOneByIdWithEntityManager(userId, em);
const notifData: IAnnouncementNotificationData = {
userPhone: user.phone,
userEmail: user.email,
title: announcement.title,
description: announcement.content.replace(/<[^>]*>/g, ""),
date: announcement.publishAt ?? new Date(),
};
await this.notificationQueue.addAnnouncementNotification(userId, notifData);
}
this.logger.log(`Successfully sent announcement ${announcementId} to ${userIds.length} users`);
await em.commit();
return true;
} catch (error) {
this.logger.error(`Failed to send announcement:`, error instanceof Error ? error.message : "Unknown error");
await em.rollback();
throw error;
}
}
//********************************** */
private async publishAnnouncement(job: Job, token?: string) {
this.logger.log(`Publishing announcement: ${job.data.announcementId} ${token}`);
}
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { Announcement } from "../entities/announcement.entity";
export class AnnouncementRepository extends EntityRepository<Announcement> {}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { UserAnnouncement } from "../entities/user-announcement.entity";
export class UserAnnouncementRepository extends EntityRepository<UserAnnouncement> {}
+3 -2
View File
@@ -7,14 +7,15 @@ import { jwtConfig } from "../../configs/jwt.config";
import { UtilsModule } from "../utils/utils.module";
import { AuthService } from "./providers/auth.service";
import { TokensService } from "./providers/tokens.service";
import { JwtStrategy } from "./strategies/jwt.strategy";
import { NotificationModule } from "../notifications/notifications.module";
import { UsersModule } from "../users/users.module";
import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
@Module({
imports: [UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), NotificationModule],
controllers: [AuthController],
providers: [AuthService, TokensService, JwtStrategy],
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+2 -2
View File
@@ -3,12 +3,12 @@ import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";
import { JWT_STRATEGY_NAME } from "../../../common/constants";
import { CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator";
import { AuthMessage } from "../../../common/enums/message.enum";
@Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY_NAME) {
export class JwtAuthGuard extends AuthGuard([CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME]) {
constructor(private reflector: Reflector) {
super();
}
@@ -1,32 +0,0 @@
// import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
// import { Reflector } from "@nestjs/core";
// import { FastifyRequest } from "fastify";
// import { PERMISSION_KEY } from "../../../common/decorators/permission.decorator";
// import { AuthMessage } from "../../../common/enums/message.enum";
// import { PermissionEnum } from "../../users/enums/permission.enum";
// @Injectable()
// export class PermissionsGuard implements CanActivate {
// constructor(private reflector: Reflector) {}
// canActivate(context: ExecutionContext) {
// const requiredPermissions = this.reflector.getAllAndOverride<PermissionEnum[]>(PERMISSION_KEY, [
// context.getHandler(),
// context.getClass(),
// ]);
// if (!requiredPermissions) return true;
// const request = context.switchToHttp().getRequest<FastifyRequest>();
// const user = request.user;
// if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
// const hasPermission = requiredPermissions.every((perm) => user.permissions.includes(perm));
// if (!hasPermission) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
// return true;
// }
// }
@@ -1,6 +1,14 @@
import { RoleEnum } from "../../users/enums/role.enum";
export interface ITokenPayload {
export interface ILocalTokenPayload {
id: string;
role: RoleEnum;
}
export interface IConsoleTokenPayload {
id: string;
permissions?: string[];
isAdmin?: boolean;
}
export interface ITokenPayload extends ILocalTokenPayload, IConsoleTokenPayload {}
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { CONSOLE_JWT_STRATEGY_NAME } from "../../../common/constants";
import { IConsoleTokenPayload } from "../interfaces/IToken-payload";
@Injectable()
export class ConsoleJwtStrategy extends PassportStrategy(Strategy, CONSOLE_JWT_STRATEGY_NAME) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: readFileSync(`${process.cwd()}/keys/public.pem`, "utf8"),
algorithms: ["RS256"],
});
}
async validate(payload: IConsoleTokenPayload) {
return { id: payload.id, permissions: payload.permissions, isAdmin: payload.isAdmin };
}
}
@@ -3,11 +3,11 @@ import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { JWT_STRATEGY_NAME } from "../../../common/constants";
import { LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
import { ITokenPayload } from "../interfaces/IToken-payload";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY_NAME) {
export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRATEGY_NAME) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -1,8 +1,12 @@
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Property, Unique } from "@mikro-orm/core";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Announcement } from "../../announcements/entities/announcement.entity";
import { Company } from "../../companies/entities/company.entity";
import { Industry } from "../../industries/entities/industry.entity";
import { Invoice } from "../../invoices/entities/invoice.entity";
import { TicketCategory } from "../../tickets/entities/ticket-category.entity";
import { Ticket } from "../../tickets/entities/ticket.entity";
import { BusinessRepository } from "../repositories/business.repository";
@Entity({ repository: () => BusinessRepository })
@@ -23,5 +27,17 @@ export class Business extends BaseEntity {
@OneToMany(() => Industry, (industry) => industry.business)
industries = new Collection<Industry>(this);
@OneToMany(() => Invoice, (invoice) => invoice.business)
invoices = new Collection<Invoice>(this);
@OneToMany(() => TicketCategory, (ticketCategory) => ticketCategory.business)
ticketCategories = new Collection<TicketCategory>(this);
@OneToMany(() => Ticket, (ticket) => ticket.business)
tickets = new Collection<Ticket>(this);
@OneToMany(() => Announcement, (announcement) => announcement.business)
announcements = new Collection<Announcement>(this);
[EntityRepositoryType]?: BusinessRepository;
}
@@ -5,6 +5,7 @@ import { CompanyListQueryDto } from "./DTO/company-list-query.dto";
import { CreateCompanyDto } from "./DTO/create-company.dto";
import { UpdateCompanyDto } from "./DTO/update-company.dto";
import { CompaniesService } from "./services/companies.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
@@ -15,36 +16,42 @@ export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
@Post()
@AuthGuards()
@ApiOperation({ summary: "create company (admin)" })
create(@Body() createCompanyDto: CreateCompanyDto, @BusinessDec() business: Business) {
return this.companiesService.create(createCompanyDto, business);
}
@Patch(":id")
@AuthGuards()
@ApiOperation({ summary: "update company (admin)" })
update(@Param() paramDto: ParamDto, @Body() updateCompanyDto: UpdateCompanyDto, @BusinessDec("id") businessId: string) {
return this.companiesService.updateCompanyById(paramDto.id, updateCompanyDto, businessId);
}
@Delete(":id")
@AuthGuards()
@ApiOperation({ summary: "delete company (admin)" })
delete(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.companiesService.deleteCompanyById(paramDto.id, businessId);
}
@Get("list")
@AuthGuards()
@ApiOperation({ summary: "get companies list (admin)" })
getCompaniesList(@Query() query: CompanyListQueryDto, @BusinessDec("id") businessId: string) {
return this.companiesService.getCompaniesList(query, businessId);
}
@Get(":id")
@AuthGuards()
@ApiOperation({ summary: "get company by id" })
get(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.companiesService.getCompanyById(paramDto.id, businessId);
}
@Patch(":id/toggle-status")
@AuthGuards()
@ApiOperation({ summary: "toggle company status (admin)" })
toggleStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.companiesService.toggleStatus(paramDto.id, businessId);
@@ -5,6 +5,7 @@ import { CreateIndustryDto } from "./DTO/create-industry.dto";
import { IndustryListQueryDto } from "./DTO/industry-list-query.dto";
import { UpdateIndustryDto } from "./DTO/update-industry.dto";
import { IndustriesService } from "./services/industries.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
@@ -16,6 +17,7 @@ export class IndustriesController {
constructor(private readonly industriesService: IndustriesService) {}
@Post()
@AuthGuards()
@ApiOperation({ summary: "create industry" })
create(@Body() createIndustryDto: CreateIndustryDto, @BusinessDec() business: Business) {
return this.industriesService.create(createIndustryDto, business);
@@ -28,24 +30,28 @@ export class IndustriesController {
}
@Patch(":id")
@AuthGuards()
@ApiOperation({ summary: "update industry" })
updateIndustry(@Param() paramDto: ParamDto, @Body() updateIndustryDto: UpdateIndustryDto, @BusinessDec("id") businessId: string) {
return this.industriesService.updateIndustry(paramDto.id, updateIndustryDto, businessId);
}
@Delete(":id")
@AuthGuards()
@ApiOperation({ summary: "delete industry" })
deleteIndustry(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.industriesService.deleteIndustry(paramDto.id, businessId);
}
@Get("list")
@AuthGuards()
@ApiOperation({ summary: "get industries list for admin" })
getIndustriesForAdmin(@Query() query: IndustryListQueryDto, @BusinessDec("id") businessId: string) {
return this.industriesService.getIndustriesListForAdmin(query, businessId);
}
@Patch(":id/toggle-status")
@AuthGuards()
@ApiOperation({ summary: "toggle industry status" })
toggleStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.industriesService.toggleStatus(paramDto.id, businessId);
@@ -3,6 +3,7 @@ import { Decimal } from "decimal.js";
import { InvoiceItem } from "./invoice-item.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { Company } from "../../companies/entities/company.entity";
import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum";
import { InvoiceStatus } from "../enums/invoice-status.enum";
@@ -10,7 +11,7 @@ import { InvoicesRepository } from "../repositories/invoices.repository";
@Entity({ repository: () => InvoicesRepository })
export class Invoice extends BaseEntity {
@Property({ type: "int", autoincrement: true, persist: false })
@Property({ type: "int", autoincrement: true })
numericId!: number & Opt;
@ManyToOne(() => Company, { nullable: false, deleteRule: "cascade" })
@@ -40,7 +41,7 @@ export class Invoice extends BaseEntity {
@Property({ type: "boolean", default: false })
isRecurring: boolean & Opt;
@Enum({ items: () => RecurringPeriodEnum, nativeEnumName: "recurring_period", nullable: true })
@Enum({ items: () => RecurringPeriodEnum, nullable: true, type: "smallint" })
recurringPeriod?: RecurringPeriodEnum;
@Property({ type: "int", default: 0 })
@@ -52,5 +53,8 @@ export class Invoice extends BaseEntity {
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] })
items = new Collection<InvoiceItem>(this);
@ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" })
business!: Business;
[EntityRepositoryType]?: InvoicesRepository;
}
@@ -3,7 +3,6 @@ export enum InvoiceStatus {
PENDING = "PENDING",
WAIT_PAYMENT = "WAIT_PAYMENT",
PAID = "PAID",
ARCHIVED = "ARCHIVED",
OVERDUE = "OVERDUE",
EXPIRED = "EXPIRED",
CANCELLED = "CANCELLED",
}
+29 -15
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { Body, Controller, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { CreateInvoiceDto } from "./DTO/create-invoice.dto";
@@ -6,56 +6,70 @@ import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoic
import { UpdateInvoiceDto } from "./DTO/update-invoice.dto";
import { InvoicesService } from "./providers/invoices.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto";
import { Business } from "../businesses/entities/business.entity";
import { RoleEnum } from "../users/enums/role.enum";
@Controller("invoices")
@UseInterceptors(BusinessInterceptor)
@AuthGuards()
export class InvoicesController {
constructor(private readonly invoiceService: InvoicesService) {}
@ApiOperation({ summary: "create an invoice (admin)" })
@Post()
createInvoice(@Body() createDto: CreateInvoiceDto) {
return this.invoiceService.createInvoiceAdmin(createDto);
createInvoice(@Body() createDto: CreateInvoiceDto, @BusinessDec() business: Business) {
return this.invoiceService.createInvoiceAdmin(createDto, business);
}
@ApiOperation({ summary: "update an invoice (admin)" })
@Patch(":id")
updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto) {
return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto);
updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto, @BusinessDec("id") businessId: string) {
return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto, businessId);
}
@ApiOperation({ summary: "get all invoices (admin)" })
@Get()
getInvoices(@Query() queryDto: InvoicesSearchQueryDto) {
return this.invoiceService.getInvoices(queryDto);
getInvoices(@Query() queryDto: InvoicesSearchQueryDto, @BusinessDec("id") businessId: string) {
return this.invoiceService.getInvoices(queryDto, businessId);
}
@ApiOperation({ summary: "get all user invoices" })
@Get("user")
getUserInvoices(@Query() queryDto: UserInvoicesSearchQueryDto, @UserDec("id") userId: string) {
return this.invoiceService.getUserInvoices(queryDto, userId);
getUserInvoices(@Query() queryDto: UserInvoicesSearchQueryDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.invoiceService.getUserInvoices(queryDto, userId, businessId);
}
@ApiOperation({ summary: "get single invoice by Id " })
@Get(":id")
getInvoiceById(@Param() paramDto: ParamDto, @UserDec("role") role: RoleEnum, @UserDec("id") userId: string) {
return this.invoiceService.getInvoiceById(paramDto.id, role, userId);
getInvoiceById(
@Param() paramDto: ParamDto,
@UserDec("role") role: RoleEnum,
@UserDec("id") userId: string,
@BusinessDec("id") businessId: string,
) {
return this.invoiceService.getInvoiceById(paramDto.id, role, userId, businessId);
}
@ApiOperation({ summary: "approve request invoice by user" })
@Post(":id/approve/request")
approveRequestInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string) {
return this.invoiceService.approveInvoiceRequest(paramDto.id, userId);
approveRequestInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.invoiceService.approveInvoiceRequest(paramDto.id, userId, businessId);
}
@ApiOperation({ summary: "approve invoice by user" })
@Patch(":id/approve")
approveInvoiceByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() verifyOtpDto: VerifyOtpWithUserId) {
return this.invoiceService.approveInvoiceByUser(paramDto.id, userId, verifyOtpDto);
approveInvoiceByUser(
@Param() paramDto: ParamDto,
@UserDec("id") userId: string,
@Body() verifyOtpDto: VerifyOtpWithUserId,
@BusinessDec("id") businessId: string,
) {
return this.invoiceService.approveInvoiceByUser(paramDto.id, userId, verifyOtpDto, businessId);
}
// @ApiOperation({ summary: "pay invoice by user" })
+2
View File
@@ -8,6 +8,7 @@ import { Invoice } from "./entities/invoice.entity";
import { InvoicesController } from "./invoices.controller";
import { InvoicesService } from "./providers/invoices.service";
import { InvoiceProcessor } from "./queue/invoice.processor";
import { BusinessesModule } from "../businesses/businesses.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationModule } from "../notifications/notifications.module";
import { UsersModule } from "../users/users.module";
@@ -21,6 +22,7 @@ import { UtilsModule } from "../utils/utils.module";
UtilsModule,
NotificationModule,
CompaniesModule,
BusinessesModule,
],
providers: [InvoicesService, InvoiceProcessor],
controllers: [InvoicesController],
@@ -7,9 +7,10 @@ import Decimal from "decimal.js";
import { AuthMessage, InvoiceMessage } from "../../../common/enums/message.enum";
import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto";
import { Business } from "../../businesses/entities/business.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompaniesService } from "../../companies/services/companies.service";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
import { RoleEnum } from "../../users/enums/role.enum";
import { UsersService } from "../../users/services/users.service";
import { OTPService } from "../../utils/providers/otp.service";
@@ -41,31 +42,7 @@ export class InvoicesService {
///********************************** */
private validateInvoiceItems(items: InvoiceItemDto[]): void {
if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED);
items.forEach((item) => {
if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE);
if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE);
if (item.discount && (item.discount < 0 || item.discount > 100)) {
throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100);
}
});
}
///********************************** */
private validateRecurringInvoice(createDto: CreateInvoiceDto): void {
if (createDto.isRecurring) {
if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED);
if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) {
throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE);
}
}
}
///********************************** */
async createInvoiceAdmin(createDto: CreateInvoiceDto) {
async createInvoiceAdmin(createDto: CreateInvoiceDto, business: Business) {
const em = this.em.fork();
try {
@@ -85,7 +62,7 @@ export class InvoicesService {
return invoiceItem;
});
const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0));
const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)).round();
const tax = totalPrice.mul(0.1);
if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE);
@@ -106,6 +83,7 @@ export class InvoicesService {
recurringPeriod: createDto.recurringPeriod,
maxRecurringCycles: createDto.maxRecurringCycles,
currentRecurringCycle: 0,
business,
});
await em.persistAndFlush(invoice);
@@ -140,13 +118,13 @@ export class InvoicesService {
//********************************** */
async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto) {
async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto, businessId: string) {
const em = this.em.fork();
try {
await em.begin();
const invoice = await em.findOne(Invoice, { id: invoiceId }, { populate: ["items"] });
const invoice = await em.findOne(Invoice, { id: invoiceId, business: { id: businessId } }, { populate: ["items"] });
if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
@@ -170,7 +148,7 @@ export class InvoicesService {
});
// Calculate total price and tax
const totalPrice = invoiceItems.reduce((sum, item) => sum.add(item.totalPrice), new Decimal(0));
const totalPrice = invoiceItems.reduce((sum, item) => sum.add(item.totalPrice), new Decimal(0)).round();
const tax = totalPrice.mul(0.1);
invoice.totalPrice = totalPrice.add(tax);
@@ -199,7 +177,7 @@ export class InvoicesService {
}
//********************************** */
async approveInvoiceRequest(invoiceId: string, userId: string) {
async approveInvoiceRequest(invoiceId: string, userId: string, businessId: string) {
const em = this.em.fork();
try {
@@ -207,7 +185,7 @@ export class InvoicesService {
const user = await this.usersService.findOneByIdWithEntityManager(userId, em);
const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em);
const invoice = await this.validateInvoiceForApproval(invoiceId, userId, businessId, em);
const existCode = await this.otpService.checkExistOtp(user.phone, "INVOICE_VERIFY");
if (existCode) {
@@ -243,7 +221,7 @@ export class InvoicesService {
//********************************** */
async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId) {
async approveInvoiceByUser(invoiceId: string, userId: string, verifyOtpDto: VerifyOtpWithUserId, businessId: string) {
const em = this.em.fork();
try {
@@ -256,7 +234,7 @@ export class InvoicesService {
const isValid = await this.otpService.verifyOtp(user.phone, code, "INVOICE_VERIFY");
if (!isValid) throw new BadRequestException(AuthMessage.INVALID_OTP);
const invoice = await this.validateInvoiceForApproval(invoiceId, userId, em);
const invoice = await this.validateInvoiceForApproval(invoiceId, userId, businessId, em);
invoice.status = InvoiceStatus.WAIT_PAYMENT;
await em.persistAndFlush(invoice);
@@ -286,8 +264,12 @@ export class InvoicesService {
}
//********************************** */
private async validateInvoiceForApproval(invoiceId: string, userId: string, em: EntityManager): Promise<Invoice> {
const invoice = await em.findOne(Invoice, { id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] });
private async validateInvoiceForApproval(invoiceId: string, userId: string, businessId: string, em: EntityManager): Promise<Invoice> {
const invoice = await em.findOne(
Invoice,
{ id: invoiceId, company: { user: { id: userId } }, business: { id: businessId } },
{ populate: ["items"] },
);
if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
@@ -296,7 +278,7 @@ export class InvoicesService {
if (invoice.status === InvoiceStatus.PAID) throw new BadRequestException(InvoiceMessage.INVOICE_ALREADY_PAID);
if (dayjs().isAfter(dayjs(invoice.dueDate).add(INVOICE.MAX_DAYS_AFTER_OVERDUE, "day"))) {
invoice.status = InvoiceStatus.EXPIRED;
invoice.status = InvoiceStatus.ARCHIVED;
await em.persistAndFlush(invoice);
throw new BadRequestException(InvoiceMessage.INVOICE_IS_OVERDUE);
}
@@ -308,21 +290,18 @@ export class InvoicesService {
///********************************** */
///********************************** */
async getInvoices(queryDto: InvoicesSearchQueryDto, businessId: string) {
const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto, businessId);
async getInvoices(queryDto: InvoicesSearchQueryDto) {
const [invoices, count] = await this.invoiceRepository.getInvoicesForAdmin(queryDto);
// Get unique users that have invoices using MikroORM's query builder
const usersThatHaveInvoices = await this.em
.createQueryBuilder(User, "user")
.select(["user.id", "user.firstName", "user.lastName", "user.email", "user.phone"])
const companiesThatHaveInvoices = await this.em
.createQueryBuilder(Company, "company")
.select(["company.id", "company.name"])
.distinct()
.join("user.invoices", "invoice")
.join("company.invoices", "invoice")
.getResult();
return {
users: usersThatHaveInvoices,
companies: companiesThatHaveInvoices,
invoices,
count,
paginate: true,
@@ -330,13 +309,19 @@ export class InvoicesService {
}
//*********************************** */
async getInvoiceById(invoiceId: string, role: RoleEnum, userId: string) {
async getInvoiceById(invoiceId: string, role: RoleEnum, userId: string, businessId: string) {
let invoice: Invoice | null;
if (role === RoleEnum.ADMIN) {
invoice = await this.invoiceRepository.findOne(invoiceId, { populate: ["items", "company"] });
if (role === RoleEnum.USER) {
invoice = await this.invoiceRepository.findOne(
{ id: invoiceId, company: { user: { id: userId } }, business: { id: businessId } },
{ populate: ["items", "company"] },
);
} else {
invoice = await this.invoiceRepository.findOne({ id: invoiceId, company: { user: { id: userId } } }, { populate: ["items"] });
invoice = await this.invoiceRepository.findOne(
{ id: invoiceId, business: { id: businessId } },
{ populate: ["items", "company", "business"] },
);
}
if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
@@ -348,13 +333,13 @@ export class InvoicesService {
//*********************************** */
async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string) {
async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const qb = this.em
.createQueryBuilder(Invoice, "invoice")
.leftJoinAndSelect("invoice.items", "items")
.where({ company: { user: { id: userId } } })
.where({ company: { user: { id: userId } }, business: { id: businessId } })
.orderBy({ createdAt: "DESC" });
if (queryDto.status) {
@@ -553,4 +538,27 @@ export class InvoicesService {
}
}
//*********************************** */
private validateInvoiceItems(items: InvoiceItemDto[]): void {
if (!items || items.length === 0) throw new BadRequestException(InvoiceMessage.ITEMS_REQUIRED);
items.forEach((item) => {
if (item.unitPrice <= 0) throw new BadRequestException(InvoiceMessage.UNIT_PRICE_MUST_BE_POSITIVE);
if (item.count <= 0) throw new BadRequestException(InvoiceMessage.COUNT_MUST_BE_POSITIVE);
if (item.discount && (item.discount < 0 || item.discount > 100)) {
throw new BadRequestException(InvoiceMessage.DISCOUNT_MUST_BE_BETWEEN_0_AND_100);
}
});
}
///********************************** */
private validateRecurringInvoice(createDto: CreateInvoiceDto): void {
if (createDto.isRecurring) {
if (!createDto.recurringPeriod) throw new BadRequestException(InvoiceMessage.RECURRING_PERIOD_REQUIRED);
if (createDto.maxRecurringCycles && createDto.maxRecurringCycles < 1) {
throw new BadRequestException(InvoiceMessage.MAX_RECURRING_CYCLES_MUST_BE_POSITIVE);
}
}
}
}
@@ -21,7 +21,6 @@ export class InvoiceProcessor extends WorkerProcessor {
}
async process(job: Job, token?: string) {
this.logger.log(job);
switch (job.name) {
case INVOICE.REMINDER_JOB_NAME:
return this.sendBillInvoiceReminder(job, token);
@@ -65,7 +64,7 @@ export class InvoiceProcessor extends WorkerProcessor {
throw new Error(`Maximum recurring cycles (${invoice.maxRecurringCycles}) reached for invoice ${invoice.id}`);
}
if (invoice.status === InvoiceStatus.CANCELLED) throw new Error(`Cannot create recurring invoice for cancelled invoice ${invoice.id}`);
if (invoice.status === InvoiceStatus.ARCHIVED) throw new Error(`Cannot create recurring invoice for archived invoice ${invoice.id}`);
}
//********************************** */
@@ -87,6 +86,7 @@ export class InvoiceProcessor extends WorkerProcessor {
recurringPeriod: invoice.recurringPeriod,
maxRecurringCycles: invoice.maxRecurringCycles,
currentRecurringCycle: invoice.currentRecurringCycle + 1,
business: invoice.business,
});
// Update the original invoice's current recurring cycle
@@ -149,10 +149,10 @@ export class InvoiceProcessor extends WorkerProcessor {
//********************************** */
private async cancelOverdueInvoice(invoice: Invoice, em: EntityManager) {
invoice.status = InvoiceStatus.CANCELLED;
invoice.status = InvoiceStatus.ARCHIVED;
await em.persistAndFlush(invoice);
this.logger.log(`Invoice ${invoice.id} has been cancelled as it is more than 10 days overdue.`);
this.logger.log(`Invoice ${invoice.id} has been archived as it is more than 10 days overdue.`);
}
//********************************** */
@@ -7,10 +7,10 @@ import { Invoice } from "../entities/invoice.entity";
export class InvoicesRepository extends EntityRepository<Invoice> {
//
async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto) {
async getInvoicesForAdmin(queryDto: InvoicesSearchQueryDto, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Invoice> = {};
const where: FilterQuery<Invoice> = { business: { id: businessId } };
if (queryDto.q) {
where.$or = [
@@ -25,7 +25,7 @@ export class InvoicesRepository extends EntityRepository<Invoice> {
}
if (queryDto.companyId) {
where.company = queryDto.companyId;
where.company = { id: queryDto.companyId };
}
if (queryDto.since || queryDto.to) {
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{title}}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
}
.content {
background-color: #ffffff;
padding: 20px;
border-radius: 5px;
border: 1px solid #dee2e6;
}
.footer {
text-align: center;
margin-top: 20px;
color: #6c757d;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="header">
<h2>{{title}}</h2>
</div>
<div class="content">
<p>{{message}}</p>
</div>
<div class="footer">
<p>This is an automated message from DanakCo. Please do not reply to this email.</p>
</div>
</body>
</html>
@@ -0,0 +1,24 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>اعلان</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>اعلان</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<div style="font-size: 24px; color: #2c3e50; text-align: center; margin: 20px 0;">{{title}}</div>
<div style="margin: 20px 0; text-align: center;">{{description}}</div>
<div style="text-align: center; color: #666; font-size: 14px;">تاریخ: {{date}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}}</title>
<style>
body, p, a { margin: 0; padding: 0; font-family: Arial, sans-serif; } body { background-color: #f5f5f5; color: #333333; text-align:
right; line-height: 1.6; } /* Container */ .container { width: 100%; max-width: 600px; margin: 40px auto; padding: 20px;
background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } /* Logo */ .logo { text-align: center;
margin-bottom: 20px; } .logo img { max-width: 50px; height: auto; } h1 { font-size: 24px; color: #1a1a1a; margin-bottom: 20px;
text-align: center; } p { font-size: 16px; color: #4a4a4a; margin-bottom: 20px; } .btn { display: inline-block; padding: 12px 24px;
margin: 20px auto; background-color: #030a11; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; text-align:
center; transition: background-color 0.3s ease; } .btn:hover { background-color: #6d7074; } a { color: #373b3f; text-decoration: none;
} a:hover { text-decoration: none; } .footer { margin-top: 30px; font-size: 14px; color: #777777; text-align: center; } .footer a {
color: #007bff; } .footer a:hover { text-decoration: underline; } /* Responsive Design */ @media (max-width: 600px) { .container {
margin: 20px auto; padding: 15px; } h1 { font-size: 20px; } p { font-size: 14px; } .btn { font-size: 14px; padding: 10px 20px; } }
</style>
</head>
<body>
<div class="container">
<!-- Logo -->
<div class="logo">
<img src="https://danakcorp.com/favicon.png" alt="DanakCorp Logo" />
</div>
<p>سلام {{fullName}} عزیز,</p>
<p>به <strong>DanakCorp</strong> خوش آمدید! لطفاً برای تأیید ایمیل خود روی دکمه زیر کلیک کنید:</p>
<div style="text-align: center">
<a href="{{link}}" class="btn">تأیید ایمیل</a>
</div>
<p>اگر دکمه بالا کار نمی‌کند، می‌توانید از لینک زیر استفاده کنید:</p>
<p style="word-break: break-all; text-align: center">
<a href="{{link}}">{{link}}</a>
</p>
<p style="color: #777777; font-size: 14px; text-align: center">
اگر شما این درخواست را ارسال نکرده‌اید، لطفاً این ایمیل را نادیده بگیرید.
</p>
<div class="footer">
<p>با تشکر،<br />تیم پشتیبانی <strong>DanakCorp</strong></p>
<p><a href="https://danakcorp.com">danakcorp.com</a></p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>لغو آزمون</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>لغو آزمون</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>آزمون زیر با موفقیت لغو شد:</p>
<div style="font-size: 20px; color: #2c3e50; text-align: center; margin: 20px 0;">{{examTitle}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>فاکتور</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>فاکتور</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات فاکتور شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>مبلغ:</span>
<span>{{price}} تومان</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ ایجاد:</span>
<span>{{createDate}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ سررسید:</span>
<span>{{dueDate}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>شناسه فاکتور:</span>
<span>{{invoiceId}}</span>
</div>
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ورود به سیستم</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>ورود به سیستم</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>ورود به سیستم در تاریخ و ساعت زیر انجام شد:</p>
<div style="font-size: 20px; color: #2c3e50; text-align: center; margin: 20px 0;">{{date}}</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>لغو پرداخت</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>لغو پرداخت</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>پرداخت مبلغ زیر به دلیل عدم پرداخت در زمان مقرر لغو شد:</p>
<div style="font-size: 24px; color: #e74c3c; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
@@ -0,0 +1,23 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>یادآوری پرداخت</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>یادآوری پرداخت</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>لطفاً نسبت به پرداخت مبلغ زیر اقدام فرمایید:</p>
<div style="font-size: 24px; color: #e74c3c; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>تیکت پشتیبانی</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>تیکت پشتیبانی</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات تیکت پشتیبانی شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>موضوع:</span>
<span>{{subject}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ:</span>
<span>{{date}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>شناسه تیکت:</span>
<span>{{ticketId}}</span>
</div>
{{#if user}}
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>کارشناس:</span>
<span>{{user}}</span>
</div>
{{/if}}
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<html dir="rtl" lang="fa">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>اعلان کیف پول</title>
</head>
<body
style="font-family: 'IRANSans', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; text-align: center;"
>
<div style="text-align: center; margin-bottom: 30px;">
<h1>اعلان کیف پول</h1>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px;">
<p>سلام،</p>
<p>جزئیات تراکنش کیف پول شما به شرح زیر است:</p>
<div style="margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>مبلغ تراکنش:</span>
<div style="font-size: 24px; color: #2ecc71; text-align: center; margin: 20px 0;">{{amount}} تومان</div>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>موجودی فعلی:</span>
<div style="font-size: 20px; color: #3498db; text-align: center; margin: 20px 0;">{{balance}} تومان</div>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>تاریخ:</span>
<span>{{date}}</span>
</div>
<div style="display: flex; justify-content: space-between; margin: 10px 0;">
<span>دلیل:</span>
<span>{{reason}}</span>
</div>
</div>
<p>با تشکر</p>
</div>
<div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
</div>
</body>
</html>
@@ -1,5 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsNotEmpty, IsString, IsUUID, Length } from "class-validator";
import { IsBoolean, IsNotEmpty, IsString, Length } from "class-validator";
import { TicketMessageEnum } from "../../../common/enums/message.enum";
@@ -16,11 +16,6 @@ export class CreateTicketCategoryDto {
@ApiProperty({ description: "Description of the ticket category", example: "درخواست‌های فنی", minLength: 3, maxLength: 500 })
description: string;
@IsNotEmpty({ message: TicketMessageEnum.USER_GROUP_ID_NOT_EMPTY })
@IsUUID("4", { message: TicketMessageEnum.USER_GROUP_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "User group ID", example: "123e4567-e89b-12d3-a456-426614174000" })
userGroupId: string;
@IsNotEmpty({ message: TicketMessageEnum.IS_ACTIVE_REQUIRED })
@IsBoolean({ message: TicketMessageEnum.IS_ACTIVE_SHOULD_BE_BOOLEAN })
@ApiProperty({ description: "Is active", example: true })
@@ -1,5 +1,5 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { PartialType } from "@nestjs/swagger";
import { CreateTicketCategoryDto } from "./create-ticket-category.dto";
export class UpdateTicketCategoryDto extends PartialType(OmitType(CreateTicketCategoryDto, ["userGroupId"])) {}
export class UpdateTicketCategoryDto extends PartialType(CreateTicketCategoryDto) {}
@@ -1,9 +1,11 @@
import { Collection, Entity, OneToMany, Property } from "@mikro-orm/core";
import { Collection, Entity, EntityRepositoryType, Formula, ManyToOne, OneToMany, Property } from "@mikro-orm/core";
import { Ticket } from "./ticket.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { TicketCategoryRepository } from "../repositories/tickets-category.repository";
@Entity()
@Entity({ repository: () => TicketCategoryRepository })
export class TicketCategory extends BaseEntity {
@Property({ type: "varchar", length: 150, unique: true })
title!: string;
@@ -14,6 +16,14 @@ export class TicketCategory extends BaseEntity {
@Property({ type: "boolean", default: true })
isActive: boolean = true;
@Formula((alias) => `(SELECT COUNT(*)::int FROM ticket WHERE ticket.category_id = ${alias}.id)`, { persist: false })
ticketCount?: number;
@OneToMany(() => Ticket, (ticket) => ticket.category)
tickets = new Collection<Ticket>(this);
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
business!: Business;
[EntityRepositoryType]?: TicketCategoryRepository;
}
@@ -1,13 +1,16 @@
import { Entity, ManyToOne, Property } from "@mikro-orm/core";
import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core";
import { TicketMessage } from "./ticket-message.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { TicketMessageAttachmentsRepository } from "../repositories/ticket-message-attachment.repository";
@Entity()
@Entity({ repository: () => TicketMessageAttachmentsRepository })
export class TicketMessageAttachment extends BaseEntity {
@Property({ type: "varchar", length: 150, nullable: true })
attachmentUrl?: string;
@ManyToOne({ entity: () => TicketMessage, deleteRule: "cascade", nullable: false })
ticketMessage!: TicketMessage;
[EntityRepositoryType]?: TicketMessageAttachmentsRepository;
}
@@ -1,11 +1,12 @@
import { Cascade, Collection, Entity, ManyToOne, OneToMany, Property } from "@mikro-orm/core";
import { Cascade, Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, Property } from "@mikro-orm/core";
import { TicketMessageAttachment } from "./ticket-message-attachment";
import { Ticket } from "./ticket.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { User } from "../../users/entities/user.entity";
import { TicketMessagesRepository } from "../repositories/tickets-message.repository";
@Entity()
@Entity({ repository: () => TicketMessagesRepository })
export class TicketMessage extends BaseEntity {
@Property({ type: "text" })
content!: string;
@@ -18,4 +19,6 @@ export class TicketMessage extends BaseEntity {
@ManyToOne(() => User, { deleteRule: "cascade", nullable: false })
author!: User;
[EntityRepositoryType]?: TicketMessagesRepository;
}
@@ -1,14 +1,16 @@
import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core";
import { Cascade, Collection, Entity, EntityRepositoryType, Enum, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core";
import { TicketCategory } from "./ticket-category.entity";
import { TicketMessage } from "./ticket-message.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { Company } from "../../companies/entities/company.entity";
import { User } from "../../users/entities/user.entity";
import { TicketPriority } from "../enums/ticket-priority.enum";
import { TicketStatus } from "../enums/ticket-status.enum";
import { TicketsRepository } from "../repositories/tickets.repository";
@Entity()
@Entity({ repository: () => TicketsRepository })
export class Ticket extends BaseEntity {
@Property({ type: "int", autoincrement: true, persist: false })
numericId!: number & Opt;
@@ -34,6 +36,11 @@ export class Ticket extends BaseEntity {
@ManyToOne(() => TicketCategory, { deleteRule: "set null", nullable: true })
category?: TicketCategory;
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
business!: Business;
@OneToMany(() => TicketMessage, (message) => message.ticket, { cascade: [Cascade.ALL] })
messages = new Collection<TicketMessage>(this);
[EntityRepositoryType]?: TicketsRepository;
}
@@ -3,6 +3,7 @@ import { BadRequestException, HttpException, Injectable, InternalServerErrorExce
import { ParamDto } from "../../../common/DTO/param.dto";
import { CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { Business } from "../../businesses/entities/business.entity";
import { Company } from "../../companies/entities/company.entity";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
@@ -38,26 +39,27 @@ export class TicketsService {
) {}
//******************************** */
async getTicketCategories() {
const ticketCategories = await this.ticketsCategoryRepository.find({});
async getTicketCategories(businessId: string) {
const ticketCategories = await this.ticketsCategoryRepository.find({ business: { id: businessId } });
return {
ticketCategories,
};
}
//******************************** */
async getCategoriesList(queryDto: SearchTicketCategoryDto) {
const [categories, count] = await this.ticketsCategoryRepository.findCategoriesList(queryDto);
async getCategoriesList(queryDto: SearchTicketCategoryDto, businessId: string) {
const [categories, count] = await this.ticketsCategoryRepository.findCategoriesList(queryDto, businessId);
return { categories, count, paginate: true };
}
//******************************** */
async createTicketCategory(createDto: CreateTicketCategoryDto) {
const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title);
async createTicketCategory(createDto: CreateTicketCategoryDto, business: Business) {
const existCategory = await this.ticketsCategoryRepository.findCategoryByTitle(createDto.title, business.id);
if (existCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_EXIST);
const ticketCategory = this.ticketsCategoryRepository.create({
...createDto,
business,
});
await this.em.persistAndFlush(ticketCategory);
@@ -69,8 +71,8 @@ export class TicketsService {
}
//******************************** */
async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id);
async updateCategory(paramDto: ParamDto, updateCategoryDto: UpdateTicketCategoryDto, businessId: string) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
if (updateCategoryDto.title) {
@@ -88,9 +90,25 @@ export class TicketsService {
message: CommonMessage.UPDATE_SUCCESS,
};
}
//******************************** */
async toggleCategoryStatus(paramDto: ParamDto) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id);
async deleteCategory(paramDto: ParamDto, businessId: string) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
if (category.tickets.length > 0) throw new BadRequestException(TicketMessageEnum.CATEGORY_HAS_TICKETS);
category.deletedAt = new Date();
await this.em.flush();
return {
message: TicketMessageEnum.CATEGORY_DELETED,
};
}
//******************************** */
async toggleCategoryStatus(paramDto: ParamDto, businessId: string) {
const category = await this.ticketsCategoryRepository.findCategoryById(paramDto.id, businessId);
if (!category) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND);
//
category.isActive = !category.isActive;
@@ -104,7 +122,7 @@ export class TicketsService {
//******************************** */
async createTicket(createDto: CreateTicketDto, userId: string) {
async createTicket(createDto: CreateTicketDto, userId: string, business: Business) {
const em = this.em.fork();
try {
@@ -142,6 +160,7 @@ export class TicketsService {
category: ticketCategory,
messages: [ticketMessage],
company,
business,
});
await em.persistAndFlush(ticket);
@@ -163,27 +182,27 @@ export class TicketsService {
//******************************** */
async getTickets(queryDto: SearchTicketQueryDto, userId: string) {
const [tickets, count] = await this.ticketsRepository.findTicketsList(queryDto, userId);
async getTickets(queryDto: SearchTicketQueryDto, userId: string, businessId: string) {
const [tickets, count] = await this.ticketsRepository.findTicketsList(queryDto, userId, businessId);
return { tickets, count, paginate: true };
}
//******************************** */
async getTicketForAdmin(queryDto: SearchTicketQueryDto, adminId: string) {
const [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId);
async getTicketForAdmin(queryDto: SearchTicketQueryDto, adminId: string, businessId: string) {
const [tickets, count] = await this.ticketsRepository.findTicketsListForAdmin(queryDto, adminId, businessId);
return { tickets, count, paginate: true };
}
//******************************** */
async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: RoleEnum) {
async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: RoleEnum, businessId: string) {
const em = this.em.fork();
try {
await em.begin();
const ticket = await this.findTicket(em, ticketId, userId, role === RoleEnum.ADMIN);
const ticket = await this.findTicket(em, ticketId, businessId, userId, role === RoleEnum.ADMIN);
if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED);
if (role === RoleEnum.ADMIN && ticket.assignedTo?.id !== userId)
@@ -224,13 +243,13 @@ export class TicketsService {
//******************************** */
async getTicketMessages(ticketId: string, userId: string, role: RoleEnum) {
async getTicketMessages(ticketId: string, userId: string, role: RoleEnum, businessId: string) {
let ticket = null;
//
if (role === RoleEnum.ADMIN) {
ticket = await this.ticketsRepository.findTicketById(ticketId);
ticket = await this.ticketsRepository.findTicketById(ticketId, businessId);
} else {
ticket = await this.ticketsRepository.findTicketById(ticketId, userId);
ticket = await this.ticketsRepository.findTicketById(ticketId, businessId, userId);
}
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
@@ -243,13 +262,13 @@ export class TicketsService {
//******************************** */
async closeTicketByUser(ticketId: string, userId: string, role: RoleEnum) {
async closeTicketByUser(ticketId: string, userId: string, role: RoleEnum, businessId: string) {
let ticket = null;
if (role === RoleEnum.ADMIN) {
ticket = await this.ticketsRepository.findTicketById(ticketId);
ticket = await this.ticketsRepository.findTicketById(ticketId, businessId);
} else {
ticket = await this.ticketsRepository.findTicketById(ticketId, userId);
ticket = await this.ticketsRepository.findTicketById(ticketId, businessId, userId);
}
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
@@ -262,8 +281,8 @@ export class TicketsService {
};
}
//******************************** * /
async openTicketByAdmin(ticketId: string) {
const ticket = await this.ticketsRepository.findTicketById(ticketId);
async openTicketByAdmin(ticketId: string, businessId: string) {
const ticket = await this.ticketsRepository.findTicketById(ticketId, businessId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
ticket.status = TicketStatus.PENDING;
@@ -275,14 +294,14 @@ export class TicketsService {
}
//******************************** */
async referTicket(ticketId: string, userId: string, referDto: ReferTicketDto) {
async referTicket(ticketId: string, userId: string, referDto: ReferTicketDto, businessId: string) {
const em = this.em.fork();
try {
await em.begin();
console.log(userId, "admin id");
const ticket = await this.ticketsRepository.findTicketById(ticketId);
const ticket = await this.ticketsRepository.findTicketById(ticketId, businessId);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
const admin = await this.usersService.findOneByIdWithEntityManager(referDto.adminId, em);
@@ -357,10 +376,14 @@ export class TicketsService {
}
//***************************** */
private async findTicket(em: EntityManager, ticketId: string, userId: string, isAdmin: boolean): Promise<Ticket> {
private async findTicket(em: EntityManager, ticketId: string, businessId: string, userId: string, isAdmin: boolean): Promise<Ticket> {
const ticket = isAdmin
? await em.findOne(Ticket, { id: ticketId, user: { id: userId } }, { populate: ["user", "assignedTo"] })
: await em.findOne(Ticket, { id: ticketId, user: { id: userId } }, { populate: ["user", "assignedTo"] });
? await em.findOne(Ticket, { id: ticketId, user: { id: userId }, business: { id: businessId } }, { populate: ["user", "assignedTo"] })
: await em.findOne(
Ticket,
{ id: ticketId, user: { id: userId }, business: { id: businessId } },
{ populate: ["user", "assignedTo"] },
);
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
return ticket;
@@ -6,41 +6,18 @@ import { TicketCategory } from "../entities/ticket-category.entity";
export class TicketCategoryRepository extends EntityRepository<TicketCategory> {
//
async findCategoryByTitle(title: string): Promise<TicketCategory | null> {
return this.findOne({ title });
async findCategoryByTitle(title: string, businessId: string): Promise<TicketCategory | null> {
return this.findOne({ title, business: { id: businessId }, deletedAt: null }, { populate: ["tickets"] });
}
async findCategoryById(id: string): Promise<TicketCategory | null> {
return this.findOne({ id });
async findCategoryById(id: string, businessId: string): Promise<TicketCategory | null> {
return this.findOne({ id, business: { id: businessId }, deletedAt: null }, { populate: ["tickets"] });
}
async findCategoriesList(queryDto: SearchTicketCategoryDto) {
async findCategoriesList(queryDto: SearchTicketCategoryDto, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const qb = this.createQueryBuilder("category")
.leftJoinAndSelect("category.group", "group")
.select(["category.*", "group.id", "group.name"]);
// Add ticket count
qb.addSelect(
this.em
.createQueryBuilder(TicketCategory, "c")
.count("t.id")
.leftJoin("c.tickets", "t")
.where("c.id = category.id")
.as("ticketsCount"),
);
// Add users count
qb.addSelect(
this.em
.createQueryBuilder(TicketCategory, "c")
.count("u.id")
.leftJoin("c.group", "g")
.leftJoin("g.users", "u")
.where("c.id = category.id")
.as("usersCount"),
);
const qb = this.createQueryBuilder("category").where({ business: { id: businessId }, deletedAt: null });
if (queryDto.isActive !== undefined) {
qb.andWhere({ isActive: queryDto.isActive });
@@ -6,11 +6,12 @@ import { Ticket } from "../entities/ticket.entity";
import { TicketStatus } from "../enums/ticket-status.enum";
export class TicketsRepository extends EntityRepository<Ticket> {
async findTicketById(id: string, userId?: string) {
async findTicketById(id: string, businessId: string, userId?: string) {
return this.findOne(
{
id,
...(userId && { user: { id: userId } }),
business: { id: businessId },
},
{
populate: ["user", "category", "assignedTo", "company"],
@@ -37,10 +38,10 @@ export class TicketsRepository extends EntityRepository<Ticket> {
}
//******************************** */
async findTicketsList(queryDto: SearchTicketQueryDto, userId: string) {
async findTicketsList(queryDto: SearchTicketQueryDto, userId: string, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Ticket> = { user: { id: userId } };
const where: FilterQuery<Ticket> = { user: { id: userId }, business: { id: businessId } };
if (queryDto.status) {
where.status = queryDto.status;
} else {
@@ -68,10 +69,10 @@ export class TicketsRepository extends EntityRepository<Ticket> {
});
}
//******************************** */
async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string) {
async findTicketsListForAdmin(queryDto: SearchTicketQueryDto, adminId: string, businessId: string) {
const { limit, skip } = PaginationUtils(queryDto);
const where: FilterQuery<Ticket> = {};
const where: FilterQuery<Ticket> = { business: { id: businessId } };
where.assignedTo = { id: adminId };
+60 -36
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { CreateTicketCategoryDto } from "./DTO/create-ticket-category.dto";
import { CreateTicketMessageDto } from "./DTO/create-ticket-message.dto";
@@ -11,71 +11,79 @@ import { UpdateTicketCategoryDto } from "./DTO/update-ticket-category.dto";
import { TicketsService } from "./providers/tickets.service";
import { AdminRoute } from "../../common/decorators/admin.decorator";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
import { Business } from "../businesses/entities/business.entity";
import { RoleEnum } from "../users/enums/role.enum";
@Controller("tickets")
@ApiTags("Tickets")
@UseInterceptors(BusinessInterceptor)
@AuthGuards()
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
//----------------- ticket categories -------------------
@ApiOperation({ summary: "Create ticket category => admin route" })
@ApiOperation({ summary: "Create ticket category (admin)" })
@Post("category")
createTicketCategory(@Body() createDto: CreateTicketCategoryDto) {
return this.ticketsService.createTicketCategory(createDto);
createTicketCategory(@Body() createDto: CreateTicketCategoryDto, @BusinessDec() business: Business) {
return this.ticketsService.createTicketCategory(createDto, business);
}
@ApiOperation({ summary: "Update ticket category => admin route" })
@ApiOperation({ summary: "Update ticket category (admin)" })
@Patch("category/:id")
updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateTicketCategoryDto) {
return this.ticketsService.updateCategory(paramDto, updateCategoryDto);
updateCategory(@Param() paramDto: ParamDto, @Body() updateCategoryDto: UpdateTicketCategoryDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.updateCategory(paramDto, updateCategoryDto, businessId);
}
@ApiOperation({ summary: "Delete ticket category (admin)" })
@Delete("category/:id")
deleteCategory(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.deleteCategory(paramDto, businessId);
}
@ApiOperation({ summary: "Get ticket categories " })
@Get("categories")
//
getTicketCategories() {
return this.ticketsService.getTicketCategories();
getTicketCategories(@BusinessDec("id") businessId: string) {
return this.ticketsService.getTicketCategories(businessId);
}
@ApiOperation({ summary: "Get ticket categories list => admin route" })
@ApiOperation({ summary: "Get ticket categories list (admin)" })
@AdminRoute()
@Get("categories-list")
getCategoriesList(@Query() queryDto: SearchTicketCategoryDto) {
return this.ticketsService.getCategoriesList(queryDto);
getCategoriesList(@Query() queryDto: SearchTicketCategoryDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.getCategoriesList(queryDto, businessId);
}
@ApiOperation({ summary: "toggle status of categories => admin route" })
@ApiOperation({ summary: "toggle status of categories (admin)" })
@AdminRoute()
@HttpCode(HttpStatus.OK)
@Post("category/toggle-status/:id")
toggleCategoryStatus(@Param() paramDto: ParamDto) {
return this.ticketsService.toggleCategoryStatus(paramDto);
toggleCategoryStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.toggleCategoryStatus(paramDto, businessId);
}
//----------------- ticket and ticket messages -------------------
@ApiOperation({ summary: "create ticket ==> user route" })
@ApiOperation({ summary: "create ticket (user)" })
@Post()
createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string) {
return this.ticketsService.createTicket(createDto, userId);
createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string, @BusinessDec() business: Business) {
return this.ticketsService.createTicket(createDto, userId, business);
}
@ApiOperation({ summary: "Get all tickets of user ==> user route" })
@ApiOperation({ summary: "Get all tickets of user (user)" })
@Get()
getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string) {
return this.ticketsService.getTickets(queryDto, userId);
getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.ticketsService.getTickets(queryDto, userId, businessId);
}
@ApiOperation({ summary: "Get all tickets ==> admin route" })
@ApiOperation({ summary: "Get all tickets (admin)" })
@AdminRoute()
@Get("admin")
getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string) {
return this.ticketsService.getTicketForAdmin(queryDto, adminId);
getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string, @BusinessDec("id") businessId: string) {
return this.ticketsService.getTicketForAdmin(queryDto, adminId, businessId);
}
@ApiOperation({ summary: "create ticket messages" })
@@ -85,34 +93,50 @@ export class TicketsController {
@Body() createDto: CreateTicketMessageDto,
@UserDec("id") userId: string,
@UserDec("role") role: RoleEnum,
@BusinessDec("id") businessId: string,
) {
return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, role);
return this.ticketsService.createTicketMessage(paramDto.id, createDto, userId, role, businessId);
}
@ApiOperation({ summary: "Get all ticket messages of user" })
@Get(":id/messages")
getTicketMessages(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @UserDec("role") role: RoleEnum) {
return this.ticketsService.getTicketMessages(paramDto.id, userId, role);
getTicketMessages(
@Param() paramDto: ParamDto,
@UserDec("id") userId: string,
@UserDec("role") role: RoleEnum,
@BusinessDec("id") businessId: string,
) {
return this.ticketsService.getTicketMessages(paramDto.id, userId, role, businessId);
}
@ApiOperation({ summary: "close ticket by user or admin" })
@HttpCode(HttpStatus.OK)
@Post(":id/close")
closedTicketByUser(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @UserDec("role") role: RoleEnum) {
return this.ticketsService.closeTicketByUser(paramDto.id, userId, role);
closedTicketByUser(
@Param() paramDto: ParamDto,
@UserDec("id") userId: string,
@UserDec("role") role: RoleEnum,
@BusinessDec("id") businessId: string,
) {
return this.ticketsService.closeTicketByUser(paramDto.id, userId, role, businessId);
}
@ApiOperation({ summary: "open ticket by admin" })
@AdminRoute()
@Post(":id/open")
openTicketByAdmin(@Param() paramDto: ParamDto) {
return this.ticketsService.openTicketByAdmin(paramDto.id);
openTicketByAdmin(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.openTicketByAdmin(paramDto.id, businessId);
}
@ApiOperation({ summary: "referer ticket ==> admin route" })
@AdminRoute()
@Post(":id/refer")
referTicket(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() referDto: ReferTicketDto) {
return this.ticketsService.referTicket(paramDto.id, userId, referDto);
referTicket(
@Param() paramDto: ParamDto,
@UserDec("id") userId: string,
@Body() referDto: ReferTicketDto,
@BusinessDec("id") businessId: string,
) {
return this.ticketsService.referTicket(paramDto.id, userId, referDto, businessId);
}
}
+3 -5
View File
@@ -5,13 +5,10 @@ import { TicketCategory } from "./entities/ticket-category.entity";
import { TicketMessage } from "./entities/ticket-message.entity";
import { Ticket } from "./entities/ticket.entity";
import { TicketsService } from "./providers/tickets.service";
import { TicketCategoryRepository } from "./repositories/tickets-category.repository";
import { TicketMessagesRepository } from "./repositories/tickets-message.repository";
import { TicketsRepository } from "./repositories/tickets.repository";
import { TicketsController } from "./tickets.controller";
import { UsersModule } from "../users/users.module";
import { TicketMessageAttachment } from "./entities/ticket-message-attachment";
import { TicketMessageAttachmentsRepository } from "./repositories/ticket-message-attachment.repository";
import { BusinessesModule } from "../businesses/businesses.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationModule } from "../notifications/notifications.module";
@@ -21,8 +18,9 @@ import { NotificationModule } from "../notifications/notifications.module";
UsersModule,
NotificationModule,
CompaniesModule,
BusinessesModule,
],
providers: [TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository, TicketMessageAttachmentsRepository],
providers: [TicketsService],
controllers: [TicketsController],
exports: [TicketsService],
})
@@ -3,6 +3,7 @@ import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, Opt, Pr
import { RefreshToken } from "./refresh-token.entity";
import { Role } from "./role.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { UserAnnouncement } from "../../announcements/entities/user-announcement.entity";
import { Company } from "../../companies/entities/company.entity";
import { UserRepository } from "../repositories/user.repository";
@@ -56,5 +57,8 @@ export class User extends BaseEntity {
@OneToMany(() => Company, (company) => company.user)
companies = new Collection<Company>(this);
@OneToMany(() => UserAnnouncement, (userAnnouncement) => userAnnouncement.user)
userAnnouncements = new Collection<UserAnnouncement>(this);
[EntityRepositoryType]?: UserRepository;
}
@@ -2,6 +2,20 @@ export function numberFormat(number: number, locale: string = "fa-IR") {
return Intl.NumberFormat(locale).format(number);
}
export function dateFormat(date: Date, locale: string = "fa-IR") {
return new Intl.DateTimeFormat(locale).format(date);
export function dateFormat(date: Date | string | number, locale: string = "fa-IR") {
try {
// Convert to Date object if it's a string or number
const dateObj = date instanceof Date ? date : new Date(date);
// Check if the date is valid
if (isNaN(dateObj.getTime())) {
console.warn("Invalid date provided to dateFormat:", date);
return "Invalid Date";
}
return new Intl.DateTimeFormat(locale).format(dateObj);
} catch (error) {
console.error("Error formatting date:", error);
return "Invalid Date";
}
}