contactrus module

This commit is contained in:
2025-12-10 10:44:18 +03:30
parent 525362dce1
commit 7ab766a563
11 changed files with 319 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ import { ReviewModule } from './modules/review/review.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PagerModule } from './modules/pager/pager.module';
import { ContactModule } from './modules/contact/contact.module';
@Module({
imports: [
@@ -50,6 +51,7 @@ import { PagerModule } from './modules/pager/pager.module';
NotificationsModule,
EventEmitterModule.forRoot(),
PagerModule,
ContactModule,
],
controllers: [],
providers: [],
+74
View File
@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Query,
UseGuards,
Delete,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ContactService } from './contact.service';
import { CreateContactDto } from './dto/create-contact.dto';
import { FindContactsDto } from './dto/find-contacts.dto';
import { UpdateContactStatusDto } from './dto/update-contact-status.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
@ApiTags('contact')
@Controller()
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post('public/contact')
@ApiOperation({ summary: 'Create a new contact (public endpoint)' })
@ApiResponse({ status: 201, description: 'Contact created successfully' })
async create(@Body() createContactDto: CreateContactDto) {
return this.contactService.create(createContactDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/contact')
@ApiOperation({ summary: 'Get paginated list of contacts (admin only)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'status', required: false, enum: ['new', 'seen'] })
async findAllPaginated(@Query() dto: FindContactsDto) {
return this.contactService.findAllPaginated(dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/contact/:id')
@ApiOperation({ summary: 'Get contact details by ID (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async findOne(@Param('id') id: string) {
return this.contactService.findOne(id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Patch('admin/contact/:id/status')
@ApiOperation({ summary: 'Update contact status (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async updateStatus(@Param('id') id: string, @Body() dto: UpdateContactStatusDto) {
return this.contactService.updateStatus(id, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Delete('admin/contact/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Hard delete a contact (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async remove(@Param('id') id: string) {
await this.contactService.remove(id);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ContactService } from './contact.service';
import { ContactController } from './contact.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Contact } from './entities/contact.entity';
import { ContactRepository } from './repositories/contact.repository';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [MikroOrmModule.forFeature([Contact]), JwtModule],
controllers: [ContactController],
providers: [ContactService, ContactRepository],
exports: [ContactRepository],
})
export class ContactModule {}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateContactDto } from './dto/create-contact.dto';
import { FindContactsDto } from './dto/find-contacts.dto';
import { UpdateContactStatusDto } from './dto/update-contact-status.dto';
import { ContactRepository } from './repositories/contact.repository';
import { Contact } from './entities/contact.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { ContactStatusEnum } from './interface/interface';
import { EntityManager } from '@mikro-orm/postgresql';
@Injectable()
export class ContactService {
constructor(
private readonly contactRepository: ContactRepository,
private readonly em: EntityManager,
) {}
async create(createContactDto: CreateContactDto): Promise<Contact> {
const contact = this.contactRepository.create({
...createContactDto,
status: ContactStatusEnum.New,
});
await this.em.persistAndFlush(contact);
return contact;
}
async findAllPaginated(dto: FindContactsDto): Promise<PaginatedResult<Contact>> {
return this.contactRepository.findAllPaginated({
page: dto.page,
limit: dto.limit,
search: dto.search,
orderBy: dto.orderBy,
order: dto.order,
status: dto.status,
});
}
async findOne(id: string): Promise<Contact> {
const contact = await this.contactRepository.findOne({ id });
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
return contact;
}
async updateStatus(id: string, dto: UpdateContactStatusDto): Promise<Contact> {
const contact = await this.contactRepository.findOne({ id });
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
contact.status = dto.status;
await this.em.persistAndFlush(contact);
return contact;
}
async remove(id: string): Promise<void> {
const contact = await this.contactRepository.findOne({ id });
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
await this.em.removeAndFlush(contact);
}
}
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsString, IsOptional, IsMobilePhone } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateContactDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'Question about menu', description: 'Contact subject' })
subject?: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'I have a question about your menu items', description: 'Contact content/message' })
content!: string;
@IsOptional()
@IsString()
@IsMobilePhone('fa-IR')
@ApiPropertyOptional({ example: '09362532122', description: 'Phone number' })
phone?: string;
}
@@ -0,0 +1,42 @@
import { IsOptional, IsNumber, IsString, IsIn, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ContactStatusEnum } from '../interface/interface';
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindContactsDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
page?: number = 1;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
limit?: number = 10;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'menu', description: 'Search in subject, content, or phone' })
search?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
orderBy?: string = 'createdAt';
@IsOptional()
@IsIn(sortOrderOptions)
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
order?: SortOrder = 'desc';
@IsOptional()
@IsEnum(ContactStatusEnum)
@ApiPropertyOptional({ enum: ContactStatusEnum, description: 'Filter by status' })
status?: ContactStatusEnum;
}
@@ -0,0 +1,11 @@
import { IsEnum, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ContactStatusEnum } from '../interface/interface';
export class UpdateContactStatusDto {
@IsNotEmpty()
@IsEnum(ContactStatusEnum)
@ApiProperty({ enum: ContactStatusEnum, description: 'New status for the contact' })
status!: ContactStatusEnum;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContactDto } from './create-contact.dto';
export class UpdateContactDto extends PartialType(CreateContactDto) {}
@@ -0,0 +1,18 @@
import { Entity, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { ContactStatusEnum } from '../interface/interface';
@Entity({ tableName: 'contacts' })
export class Contact extends BaseEntity {
@Property({ nullable: true })
subject?: string | null = null;
@Property()
content!: string;
@Property({ nullable: true })
phone?: string | null = null;
@Property({ default: ContactStatusEnum.New })
status: ContactStatusEnum = ContactStatusEnum.New;
}
@@ -0,0 +1,4 @@
export enum ContactStatusEnum {
New = 'new',
Seen = 'seen',
}
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Contact } from '../entities/contact.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { ContactStatusEnum } from '../interface/interface';
type FindContactsOpts = {
page?: number;
limit?: number;
search?: string;
orderBy?: string;
order?: 'asc' | 'desc';
status?: ContactStatusEnum;
};
@Injectable()
export class ContactRepository extends EntityRepository<Contact> {
constructor(readonly em: EntityManager) {
super(em, Contact);
}
/**
* Find contacts with pagination and optional filters.
* Supports: search (subject/content/phone), status, ordering.
*/
async findAllPaginated(opts: FindContactsOpts = {}): Promise<PaginatedResult<Contact>> {
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', status } = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Contact> = {};
if (status) {
where.status = status;
}
if (search) {
const pattern = `%${search}%`;
where.$or = [
{ subject: { $ilike: pattern } },
{ content: { $ilike: pattern } },
{ phone: { $ilike: pattern } },
];
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}