update: notification

This commit is contained in:
Mahyargdz
2025-05-21 15:35:09 +03:30
parent 8da977291b
commit b57dc5319f
19 changed files with 151 additions and 72 deletions
+3
View File
@@ -601,4 +601,7 @@ export const enum BusinessMessage {
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است",
DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است",
DOMAIN_REQUIRED = "دامنه مورد نیاز است",
DOMAIN_MUST_BE_STRING = "دامنه باید یک رشته باشد",
STAFF_NOT_FOUND = "کاربر ادمین یافت نشد",
}
+1
View File
@@ -9,6 +9,7 @@ import { getSwaggerDocument } from "./configs/swagger.config";
import { HttpExceptionFilter } from "./core/filters/http-exception.filters";
import { PaginationInterceptor } from "./core/interceptors/pagination.interceptor";
import { ResponseInterceptor } from "./core/interceptors/response.interceptor";
async function bootstrap() {
const logger = new Logger("APP");
@@ -1,14 +1,11 @@
import { IsNotEmpty, IsOptional, IsString, Matches } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, Matches } from "class-validator";
import { BusinessMessage } from "../../../common/enums/message.enum";
export class UpdateBusinessDomainDto {
@IsNotEmpty()
@IsString()
@IsNotEmpty({ message: BusinessMessage.DOMAIN_REQUIRED })
@IsString({ message: BusinessMessage.DOMAIN_MUST_BE_STRING })
@Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, { message: "Domain must be a valid domain name (e.g., example.com)" })
@ApiProperty({ description: "دامنه بیسنس", example: "example.com" })
domain: string;
}
export class VerifyDomainDto {
@IsOptional()
@IsString()
businessId?: string;
}
@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
import { UpdateBusinessDomainDto } from "./DTO/business-domain.dto";
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
@@ -10,6 +10,7 @@ import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
@Controller("business")
@ApiHeader({ name: "x-business-id", description: "Business ID" })
export class BusinessesController {
constructor(
private readonly businessesService: BusinessesService,
@@ -0,0 +1,25 @@
import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core";
import { Business } from "./business.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { BusinessStaffRepository } from "../repositories/business-staff.repository";
@Entity({ repository: () => BusinessStaffRepository })
export class BusinessStaff extends BaseEntity {
@Property({ type: "uuid", nullable: false })
danakUserId: string;
@Property({ type: "varchar", length: 255, nullable: false })
fullName: string;
@Property({ type: "varchar", length: 255, nullable: false })
email: string;
@Property({ type: "varchar", length: 255, nullable: false })
phone: string;
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
business: Business;
[EntityRepositoryType]?: BusinessStaffRepository;
}
@@ -1,5 +1,6 @@
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Opt, Property, Unique } from "@mikro-orm/core";
import { BusinessStaff } from "./business-staff.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Announcement } from "../../announcements/entities/announcement.entity";
import { Company } from "../../companies/entities/company.entity";
@@ -67,5 +68,8 @@ export class Business extends BaseEntity {
@OneToMany(() => User, (user) => user.business)
users = new Collection<User>(this);
@OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business)
businessStaffs = new Collection<BusinessStaff>(this);
[EntityRepositoryType]?: BusinessRepository;
}
@@ -6,6 +6,7 @@ import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { SUBSCRIPTIONS } from "../constant";
import { BusinessStaff } from "../entities/business-staff.entity";
import { Business } from "../entities/business.entity";
import { IBusinessProvisioningJob } from "../interfaces/IBusiness";
@@ -29,7 +30,7 @@ export class BusinessProvisioningProcessor extends WorkerProcessor {
private async handleBusinessCreated(job: Job<IBusinessProvisioningJob>) {
const em = this.em.fork();
const { subscriptionId, serviceId, serviceName, businessName, slug } = job.data;
const { subscriptionId, serviceId, serviceName, businessName, slug, userId } = job.data;
// Only act if the event is for this service
if (serviceId !== SUBSCRIPTIONS.SERVICE_ID) {
@@ -50,5 +51,20 @@ export class BusinessProvisioningProcessor extends WorkerProcessor {
} else {
this.logger.debug(`Business already exists for subscription ${subscriptionId}`);
}
const user = await em.findOne(BusinessStaff, { danakUserId: userId });
if (!user) {
const user = em.create(BusinessStaff, {
danakUserId: userId,
fullName: `${businessName} Admin`,
email: `${slug}@${businessName}.com`,
phone: "09123456789",
business: business,
});
await em.persistAndFlush(user);
this.logger.log(`Created business staff for subscription ${subscriptionId}`);
} else {
this.logger.debug(`Business staff already exists for subscription ${subscriptionId}`);
}
}
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/core";
import { BusinessStaff } from "../entities/business-staff.entity";
export class BusinessStaffRepository extends EntityRepository<BusinessStaff> {}
@@ -1,11 +1,16 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable } from "@nestjs/common";
import { BusinessMessage } from "../../../common/enums/message.enum";
import { BusinessStaff } from "../entities/business-staff.entity";
import { BusinessRepository } from "../repositories/business.repository";
@Injectable()
export class BusinessesService {
constructor(private readonly businessRepository: BusinessRepository) {}
constructor(
private readonly businessRepository: BusinessRepository,
private readonly em: EntityManager,
) {}
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
const business = await this.businessRepository.findOne({ danakSubscriptionId, deletedAt: null });
@@ -27,4 +32,32 @@ export class BusinessesService {
return business;
}
/*******************************/
async findAdminWithLeastTickets() {
// First query to get the ID of the business staff with the least tickets
const result = await this.em.getConnection().execute(
`
SELECT bs.id
FROM business_staff bs
WHERE bs.deleted_at IS NULL
ORDER BY (
SELECT COUNT(*) FROM ticket t WHERE t.assigned_to_id = bs.id
) ASC
LIMIT 1
`,
);
if (result.length === 0) return null;
return this.em.findOne(BusinessStaff, { id: result[0].id });
}
/*******************************/
async findOneByIdWithEntityManager(staffId: string, em: EntityManager) {
const staff = await em.findOne(BusinessStaff, { id: staffId });
if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
return staff;
}
}
@@ -72,6 +72,13 @@ export class CompaniesController {
return this.companiesService.toggleStatus(paramDto.id, businessId);
}
@Get("user")
@AuthGuards()
@ApiOperation({ summary: "get user companies" })
getUserCompany(@UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.companiesService.getUserCompany(userId, businessId);
}
@Post("requests")
@AuthGuards()
@ApiOperation({ summary: "submit company registration request (user)" })
@@ -171,7 +171,7 @@ export class CompaniesService {
{ id, deletedAt: null, isActive: true, status: CompanyStatus.APPROVED, business: { id: businessId } },
{
populate: ["industry", "user", "products", "services"],
fields: ["*", "industry.id", "industry.title", "user.id"],
fields: ["*", "industry.id", "industry.title", "user.id", "user.firstName", "user.lastName", "user.phone", "user.email"],
},
);
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
@@ -222,6 +222,20 @@ export class CompaniesService {
}
//-----------------------------------
async getUserCompany(userId: string, businessId: string) {
const company = await this.companyRepository.findOne(
{ user: { id: userId }, business: { id: businessId }, deletedAt: null },
{
populate: ["industry", "user", "products", "services"],
fields: ["*", "industry.id", "industry.title", "user.id", "user.firstName", "user.lastName", "user.phone", "user.email"],
},
);
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
return { company };
}
//-----------------------------------
async findOneByIdWithEntityManager(id: string, em: EntityManager) {
const company = await em.findOne(Company, { id, deletedAt: null }, { populate: ["user"] });
if (!company) throw new BadRequestException(CompanyMessage.COMPANY_NOT_FOUND);
@@ -288,10 +302,13 @@ export class CompaniesService {
if (role === RoleEnum.USER) {
request = await this.companyRequestRepository.findOne(
{ id, business: { id: businessId }, user: { id: userId }, deletedAt: null },
{ populate: ["company"] },
{ populate: ["company", "company.services", "company.products", "user"] },
);
} else {
request = await this.companyRequestRepository.findOne({ id, business: { id: businessId }, deletedAt: null }, { populate: ["company"] });
request = await this.companyRequestRepository.findOne(
{ id, business: { id: businessId }, deletedAt: null },
{ populate: ["company", "company.services", "company.products", "business", "user"] },
);
}
if (!request) throw new BadRequestException(CompanyMessage.COMPANY_REQUEST_NOT_FOUND);
@@ -303,7 +320,7 @@ export class CompaniesService {
async getCompanyRequestsUser(userId: string, businessId: string) {
const requests = await this.companyRequestRepository.findOne(
{ user: { id: userId }, business: { id: businessId }, deletedAt: null },
{ populate: ["company"], orderBy: { createdAt: "DESC" } },
{ populate: ["company", "company.services", "company.products", "user"], orderBy: { createdAt: "DESC" } },
);
return { requests };
}
@@ -4,7 +4,6 @@ import { ApiOperation } from "@nestjs/swagger";
import { SearchNotificationQueryDto } from "./DTO/search-notification-query.dto";
import { NotificationsService } from "./providers/notifications.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { Pagination } from "../../common/decorators/pagination.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
@@ -16,7 +15,6 @@ export class NotificationController {
//********************* */
@ApiOperation({ summary: "all notifications by user" })
@Pagination()
@Get()
getAllNotifications(@UserDec("id") userId: string, @Query() queryDto: SearchNotificationQueryDto) {
return this.notificationService.getAllNotifications(queryDto, userId);
@@ -83,7 +83,8 @@ export class NotificationsService {
//************************ */
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
return await this.notificationRepository.getAllNotifications(queryDto, recipientId);
const [notifications, count] = await this.notificationRepository.getAllNotifications(queryDto, recipientId);
return { notifications, count, paginate: true };
}
//************************ */
@@ -8,7 +8,7 @@ export class NotificationRepository extends EntityRepository<Notification> {
async getAllNotifications(queryDto: SearchNotificationQueryDto, recipientId: string) {
const { skip, limit } = PaginationUtils(queryDto);
const qb = this.qb("notification").where({ recipientId });
const qb = this.qb("notification").where({ recipient: { id: recipientId } });
if (queryDto.isRead !== undefined) {
qb.andWhere({ isRead: queryDto.isRead === 1 });
@@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany
import { TicketMessageAttachment } from "./ticket-message-attachment";
import { Ticket } from "./ticket.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { BusinessStaff } from "../../businesses/entities/business-staff.entity";
import { User } from "../../users/entities/user.entity";
import { TicketMessagesRepository } from "../repositories/tickets-message.repository";
@@ -18,7 +19,10 @@ export class TicketMessage extends BaseEntity {
ticket!: Ticket;
@ManyToOne(() => User, { deleteRule: "cascade", nullable: true })
author!: User | null;
author?: User | null;
@ManyToOne(() => BusinessStaff, { deleteRule: "cascade", nullable: true })
externalAuthor?: BusinessStaff | null;
[EntityRepositoryType]?: TicketMessagesRepository;
}
@@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, EntityRepositoryType, Enum, ManyToOne, One
import { TicketCategory } from "./ticket-category.entity";
import { TicketMessage } from "./ticket-message.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { BusinessStaff } from "../../businesses/entities/business-staff.entity";
import { Business } from "../../businesses/entities/business.entity";
import { Company } from "../../companies/entities/company.entity";
import { User } from "../../users/entities/user.entity";
@@ -27,8 +28,8 @@ export class Ticket extends BaseEntity {
@ManyToOne(() => Company, { deleteRule: "set null", nullable: true })
company?: Company;
@ManyToOne(() => User, { deleteRule: "set null", nullable: true })
assignedTo?: User;
@ManyToOne(() => BusinessStaff, { deleteRule: "set null", nullable: true })
assignedTo?: BusinessStaff;
@ManyToOne(() => User, { deleteRule: "cascade", nullable: false })
user!: User;
@@ -2,8 +2,10 @@ import { Collection, EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, HttpException, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ParamDto } from "../../../common/DTO/param.dto";
import { CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { BusinessMessage, CommonMessage, CompanyMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum";
import { BusinessStaff } from "../../businesses/entities/business-staff.entity";
import { Business } from "../../businesses/entities/business.entity";
import { BusinessesService } from "../../businesses/services/businesses.service";
import { Company } from "../../companies/entities/company.entity";
import { NotificationQueue } from "../../notifications/queue/notification.queue";
import { User } from "../../users/entities/user.entity";
@@ -34,6 +36,7 @@ export class TicketsService {
private readonly ticketsCategoryRepository: TicketCategoryRepository,
private readonly ticketMessagesRepository: TicketMessagesRepository,
private readonly usersService: UsersService,
private readonly businessesService: BusinessesService,
private readonly notificationQueue: NotificationQueue,
private readonly em: EntityManager,
) {}
@@ -209,14 +212,20 @@ export class TicketsService {
// if (role !== RoleEnum.ADMIN && ticket.assignedTo?.id !== userId) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
let user = null;
let externalAuthor = null;
if (role === RoleEnum.USER) {
user = await em.findOne(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
} else {
externalAuthor = await em.findOne(BusinessStaff, { id: userId });
if (!externalAuthor) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
await this.sendNotificationForAdminResponse(ticket, externalAuthor);
}
const ticketMessage = em.create(TicketMessage, {
content: createDto.content,
author: user,
externalAuthor,
ticket,
});
await em.persistAndFlush(ticketMessage);
@@ -228,13 +237,6 @@ export class TicketsService {
this.updateTicketStatus(ticket, role === RoleEnum.ADMIN);
await em.persistAndFlush(ticket);
if (role !== RoleEnum.USER) {
//TODO Fix this
console.log(user, "user");
// await this.sendNotificationForAdminResponse(ticket, user);
console.log(this.sendNotificationForAdminResponse, "sendNotificationForAdminResponse");
}
await em.commit();
return {
message: TicketMessageEnum.MESSAGE_CREATED,
@@ -310,7 +312,7 @@ export class TicketsService {
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);
const admin = await this.businessesService.findOneByIdWithEntityManager(referDto.adminId, em);
ticket.assignedTo = admin;
await em.flush();
@@ -355,7 +357,7 @@ export class TicketsService {
//***************************** */
private async assignTicketAndNotify(ticket: Ticket, user: User, em: EntityManager) {
const assignedUser = await this.usersService.findAdminWithLeastTickets();
const assignedUser = await this.businessesService.findAdminWithLeastTickets();
//
if (assignedUser) {
@@ -412,7 +414,7 @@ export class TicketsService {
}
//***************************** */
private async sendNotificationForAdminResponse(ticket: Ticket, admin: User) {
private async sendNotificationForAdminResponse(ticket: Ticket, admin: BusinessStaff) {
await this.notificationQueue.addAnswerTicketNotification(ticket.user.id, {
ticketId: ticket.numericId.toString(),
subject: ticket.subject,
@@ -421,6 +423,4 @@ export class TicketsService {
userEmail: admin.email,
});
}
//***************************** */
}
@@ -26,8 +26,7 @@ export class TicketsRepository extends EntityRepository<Ticket> {
"user.firstName",
"user.lastName",
"assignedTo.id",
"assignedTo.firstName",
"assignedTo.lastName",
"assignedTo.fullName",
"company.id",
"company.name",
"category.id",
@@ -97,8 +96,7 @@ export class TicketsRepository extends EntityRepository<Ticket> {
"user.firstName",
"user.lastName",
"assignedTo.id",
"assignedTo.firstName",
"assignedTo.lastName",
"assignedTo.fullName",
"company.id",
"company.name",
],
@@ -50,39 +50,7 @@ export class UsersService {
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return user;
}
/*******************************/
async findAdminWithLeastTickets() {
// // Find all admins with their assigned tickets
// const admins = await this.userRepository.find(
// { role: { name: RoleEnum.ADMIN } },
// { populate: ['role', 'assignedTickets'] }
// );
// if (!admins.length) return null;
// // Find the admin with the fewest assigned tickets
// return admins.reduce((minAdmin, admin) =>
// (minAdmin.assignedTickets.length <= admin.assignedTickets.length ? minAdmin : admin)
// );
const result = await this.em.getConnection().execute(
`
SELECT u.*
FROM "user" u
INNER JOIN "role" r ON u.role_id = r.id
WHERE r.name = ?
ORDER BY (
SELECT COUNT(*) FROM "ticket" t WHERE t.assigned_to_id = u.id
) ASC
LIMIT 1
`,
[RoleEnum.ADMIN],
);
// If you want a User entity, load it by id:
if (result.length === 0) return null;
return this.userRepository.findOne({ id: result[0].id }, { populate: ["role"] });
}
/*******************************/
async createUser(registerDto: CompleteRegistrationDto | CreateCompanyDto, hashedPassword: string, business: Business, em: EntityManager) {