diff --git a/src/app.module.ts b/src/app.module.ts index 87d56b6..7c23489 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [], diff --git a/src/modules/contact/contact.controller.ts b/src/modules/contact/contact.controller.ts new file mode 100644 index 0000000..9893085 --- /dev/null +++ b/src/modules/contact/contact.controller.ts @@ -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); + } +} diff --git a/src/modules/contact/contact.module.ts b/src/modules/contact/contact.module.ts new file mode 100644 index 0000000..50d2fbd --- /dev/null +++ b/src/modules/contact/contact.module.ts @@ -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 {} diff --git a/src/modules/contact/contact.service.ts b/src/modules/contact/contact.service.ts new file mode 100644 index 0000000..73bcd33 --- /dev/null +++ b/src/modules/contact/contact.service.ts @@ -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 { + const contact = this.contactRepository.create({ + ...createContactDto, + status: ContactStatusEnum.New, + }); + await this.em.persistAndFlush(contact); + return contact; + } + + async findAllPaginated(dto: FindContactsDto): Promise> { + 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 { + 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 { + 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 { + const contact = await this.contactRepository.findOne({ id }); + if (!contact) { + throw new NotFoundException(`Contact with ID ${id} not found`); + } + await this.em.removeAndFlush(contact); + } +} diff --git a/src/modules/contact/dto/create-contact.dto.ts b/src/modules/contact/dto/create-contact.dto.ts new file mode 100644 index 0000000..75a380d --- /dev/null +++ b/src/modules/contact/dto/create-contact.dto.ts @@ -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; +} diff --git a/src/modules/contact/dto/find-contacts.dto.ts b/src/modules/contact/dto/find-contacts.dto.ts new file mode 100644 index 0000000..9e737b0 --- /dev/null +++ b/src/modules/contact/dto/find-contacts.dto.ts @@ -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; +} + diff --git a/src/modules/contact/dto/update-contact-status.dto.ts b/src/modules/contact/dto/update-contact-status.dto.ts new file mode 100644 index 0000000..2b60009 --- /dev/null +++ b/src/modules/contact/dto/update-contact-status.dto.ts @@ -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; +} + diff --git a/src/modules/contact/dto/update-contact.dto.ts b/src/modules/contact/dto/update-contact.dto.ts new file mode 100644 index 0000000..366df50 --- /dev/null +++ b/src/modules/contact/dto/update-contact.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContactDto } from './create-contact.dto'; + +export class UpdateContactDto extends PartialType(CreateContactDto) {} diff --git a/src/modules/contact/entities/contact.entity.ts b/src/modules/contact/entities/contact.entity.ts new file mode 100644 index 0000000..19f9b6c --- /dev/null +++ b/src/modules/contact/entities/contact.entity.ts @@ -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; +} diff --git a/src/modules/contact/interface/interface.ts b/src/modules/contact/interface/interface.ts new file mode 100644 index 0000000..8e71bfb --- /dev/null +++ b/src/modules/contact/interface/interface.ts @@ -0,0 +1,4 @@ +export enum ContactStatusEnum { + New = 'new', + Seen = 'seen', +} diff --git a/src/modules/contact/repositories/contact.repository.ts b/src/modules/contact/repositories/contact.repository.ts new file mode 100644 index 0000000..ba76030 --- /dev/null +++ b/src/modules/contact/repositories/contact.repository.ts @@ -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 { + 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> { + const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', status } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + 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, + }, + }; + } +} +