diff --git a/src/form/dto/query-form.dto.ts b/src/form/dto/query-form.dto.ts new file mode 100644 index 0000000..12fec3f --- /dev/null +++ b/src/form/dto/query-form.dto.ts @@ -0,0 +1,47 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export enum SortOrder { + ASC = 'asc', + DESC = 'desc', +} + +export enum SortBy { + NAME = 'name', + CREATED_AT = 'createdAt', + UPDATED_AT = 'updatedAt', + STATUS = 'status', +} + +export class QueryFormDto { + @ApiPropertyOptional({ example: 1 }) + @Type(() => Number) + @IsInt() + @Min(1) + @IsOptional() + page?: number = 1; + + @ApiPropertyOptional({ example: 10 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + limit?: number = 10; + + @ApiPropertyOptional({ example: '' }) + @IsString() + @IsOptional() + search?: string; + + @ApiPropertyOptional({ enum: SortBy, example: SortBy.CREATED_AT }) + @IsEnum(SortBy) + @IsOptional() + sortBy?: SortBy = SortBy.CREATED_AT; + + @ApiPropertyOptional({ enum: SortOrder, example: SortOrder.DESC }) + @IsEnum(SortOrder) + @IsOptional() + sortOrder?: SortOrder = SortOrder.DESC; +} diff --git a/src/form/form.controller.ts b/src/form/form.controller.ts index c97bc0a..073a2ff 100644 --- a/src/form/form.controller.ts +++ b/src/form/form.controller.ts @@ -6,12 +6,13 @@ import { Patch, Param, Delete, + Query, } from '@nestjs/common'; import { FormService } from './form.service'; import { ApiOperation } from '@nestjs/swagger'; import { UpdateFieldDto } from './dto/update-field.dto'; import { CreateUpdateDto } from './dto/create-update.dto'; - +import { QueryFormDto } from './dto/query-form.dto'; @Controller('form') export class FormController { @@ -24,9 +25,9 @@ export class FormController { } @Get() - @ApiOperation({ summary: 'Find all forms' }) - async findAll() { - return await this.formService.findAll(); + @ApiOperation({ summary: 'Get all forms with pagination, search, sort' }) + findAll(@Query() query: QueryFormDto) { + return this.formService.findAll(query); } @Get(':formId') diff --git a/src/form/form.service.ts b/src/form/form.service.ts index 2329808..2dad1eb 100644 --- a/src/form/form.service.ts +++ b/src/form/form.service.ts @@ -1,4 +1,3 @@ -import { FormModule } from './form.module'; import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Form, FormDocument, Status } from './schemas/form.schema'; @@ -6,7 +5,20 @@ import { Model } from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { UpdateFieldDto } from './dto/update-field.dto'; import { CreateUpdateDto } from './dto/create-update.dto'; +import { QueryFormDto } from './dto/query-form.dto'; +import { FormQueryBuilder } from './helpers/form-query.builder'; +import { PaginatedResponse } from 'src/interfaces/paginated-response.interface'; +// Reusable projections +const LIST_PROJECTION = { + _id: 0, + id: 1, + name: 1, + status: 1, + deletedAt: 1, + createdAt: 1, +}; +const DETAIL_PROJECTION = { _id: 0, __v: 0 }; @Injectable() export class FormService { @@ -30,18 +42,50 @@ export class FormService { const updatedForm = await this.formModel.findOneAndUpdate( { id: dto.id }, { $push: { fields: newField } }, - { new: true }, + { new: true, projection: DETAIL_PROJECTION }, ); + if (!updatedForm) throw new NotFoundException(`Form ${dto.id} not found`); - return form; + return updatedForm; } - async findAll(): Promise { - return await this.formModel.find().exec(); + async findAll(query: QueryFormDto): Promise> { + const filter = FormQueryBuilder.buildFilter(query); + const sort = FormQueryBuilder.buildSort(query); + const { skip, limit } = FormQueryBuilder.buildPagination(query); + + // Run count and data fetch in parallel + const [total, data] = await Promise.all([ + this.formModel.countDocuments(filter), + this.formModel + .find(filter) + .select(DETAIL_PROJECTION) + .sort(sort) + .skip(skip) + .limit(limit) + .exec(), + ]); + + const page = query.page ?? 1; + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + hasNext: page * limit < total, + hasPrev: page > 1, + }, + }; } async find(formId: string): Promise
{ - const form = await this.formModel.findOne({ id: formId, deletedAt:null }).exec(); + const form = await this.formModel + .findOne({ id: formId, deletedAt: null }) + .select(LIST_PROJECTION) + .exec(); if (!form) throw new NotFoundException(`Form ${formId} not found`); return form; } @@ -63,6 +107,7 @@ export class FormService { { new: true, arrayFilters: [{ 'elem.id': fieldId }], + projection: DETAIL_PROJECTION, }, ); if (!form) throw new NotFoundException(`Form ${formId} not found`); @@ -73,7 +118,7 @@ export class FormService { const form = await this.formModel.findOneAndUpdate( { id: formId }, { $pull: { fields: { id: fieldId } } }, - { new: true }, + { new: true, projection: DETAIL_PROJECTION }, ); if (!form) throw new NotFoundException(`Form ${formId} not found`); return form; @@ -81,13 +126,13 @@ export class FormService { async softDelete(FormId: string): Promise { const form = await this.formModel.findOneAndUpdate( - { id: FormId, deletedAt:null }, // prevent double deletion + { id: FormId, deletedAt: null }, // prevent double deletion { $set: { deletedAt: new Date(), }, }, - { new: true }, + { new: true, projection: DETAIL_PROJECTION }, ); if (!form) throw new NotFoundException(`Form ${FormId} not found`); @@ -96,13 +141,13 @@ export class FormService { async restore(FormId: string): Promise { const form = await this.formModel.findOneAndUpdate( - { id: FormId, deletedAt:{$ne:null} }, // only restore if actually deleted + { id: FormId, deletedAt: { $ne: null } }, // only restore if actually deleted { $set: { deletedAt: null, }, }, - { new: true }, + { new: true, projection: DETAIL_PROJECTION }, ); if (!form) throw new NotFoundException(`Form ${FormId} not found`); return form; diff --git a/src/form/helpers/form-query.builder.ts b/src/form/helpers/form-query.builder.ts new file mode 100644 index 0000000..38774cf --- /dev/null +++ b/src/form/helpers/form-query.builder.ts @@ -0,0 +1,30 @@ +import { QueryFormDto, SortOrder } from '../dto/query-form.dto'; + +export class FormQueryBuilder { + static buildFilter(query: QueryFormDto): Record { + const filter: Record = {}; + + if (query.search) { + filter.name = { $regex: query.search, $options: 'i' }; + } + + return filter; + } + + // Build sort + static buildSort(query: QueryFormDto): Record { + return { + [query.sortBy ?? 'createdAt']: query.sortOrder === SortOrder.ASC ? 1 : -1, + }; + } + + // Build pagination + static buildPagination(query: QueryFormDto): { skip: number; limit: number } { + const page = query.page ?? 1; + const limit = query.limit ?? 10; + return { + skip: (page - 1) * limit, + limit, + }; + } +} diff --git a/src/interfaces/paginated-response.interface.ts b/src/interfaces/paginated-response.interface.ts new file mode 100644 index 0000000..44969ff --- /dev/null +++ b/src/interfaces/paginated-response.interface.ts @@ -0,0 +1,11 @@ +export interface PaginatedResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +}