Commit a20e2148 by Augusto

0.0.6

parent 023e6ff8
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL NOT NULL,
"url" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"candidateId" INTEGER NOT NULL,
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Photo_candidateId_idx" ON "Photo"("candidateId");
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_candidateId_fkey" FOREIGN KEY ("candidateId") REFERENCES "Candidate"("id") ON DELETE CASCADE ON UPDATE CASCADE;
\ No newline at end of file
......@@ -55,6 +55,7 @@ model Site {
longitude Float
type String?
isDigi Boolean @default(false)
isReported Boolean @default(false)
companies CompanyName[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
......
......@@ -51,7 +51,7 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
const port = process.env.PORT ?? 3000;
const port = process.env.PORT ?? 3001;
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation is available at: http://localhost:${port}/docs`);
......
......@@ -104,7 +104,7 @@ export class CandidatesController {
}
@Delete(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN)
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.PARTNER, Role.MANAGER)
@ApiOperation({ summary: 'Delete a candidate' })
@ApiResponse({ status: 200, description: 'The candidate has been successfully deleted.', type: CandidateResponseDto })
@ApiResponse({ status: 404, description: 'Candidate not found.' })
......
......@@ -2,10 +2,11 @@ import { IsString, IsNumber, IsOptional, IsEnum, IsBoolean } from 'class-validat
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum CandidateType {
TOWER = 'TOWER',
ROOFTOP = 'ROOFTOP',
GROUND = 'GROUND',
OTHER = 'OTHER',
Greenfield = 'Greenfield',
Indoor = 'Indoor',
Micro = 'Micro',
Rooftop = 'Rooftop',
Tunel = 'Tunel',
}
export enum CandidateStatus {
......@@ -16,7 +17,7 @@ export enum CandidateStatus {
MNO_VALIDATION = 'MNO_VALIDATION',
CLOSING = 'CLOSING',
SEARCH_AREA = 'SEARCH_AREA',
PAM = 'PAM'
}
export class CreateCandidateDto {
......
......@@ -18,7 +18,7 @@ export class CommentsController {
constructor(private readonly commentsService: CommentsService) { }
@Post()
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Create a new comment' })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({ status: 201, description: 'The comment has been successfully created.', type: CommentResponseDto })
......@@ -49,7 +49,7 @@ export class CommentsController {
}
@Delete(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN)
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({ status: 200, description: 'The comment has been successfully deleted.', type: CommentResponseDto })
@ApiResponse({ status: 404, description: 'Comment not found.' })
......@@ -58,7 +58,7 @@ export class CommentsController {
}
@Put(':id')
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN)
@Roles(Role.ADMIN, Role.MANAGER, Role.OPERATOR, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({ summary: 'Update a comment' })
@ApiBody({ type: UpdateCommentDto })
@ApiResponse({ status: 200, description: 'The comment has been successfully updated.', type: CommentResponseDto })
......
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum, IsInt, Min, IsBoolean } from 'class-validator';
import { Transform, Type } from 'class-transformer';
......@@ -8,56 +8,56 @@ export enum OrderDirection {
}
export class FindSitesDto {
@ApiPropertyOptional({ description: 'Page number (1-based)', default: 1 })
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Number of items per page', default: 10 })
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({ description: 'Filter by site code' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
siteCode?: string;
@ApiPropertyOptional({ description: 'Filter by site name' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
siteName?: string;
@ApiPropertyOptional({ description: 'Filter by site type' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
type?: string;
@ApiPropertyOptional({ description: 'Filter by site address' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional({ description: 'Filter by site city' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
city?: string;
@ApiPropertyOptional({ description: 'Filter by site state' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'Filter by site country' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
country?: string;
@ApiPropertyOptional({ description: 'Filter by isDigi status', default: undefined })
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
......@@ -67,7 +67,7 @@ export class FindSitesDto {
})
isDigi?: boolean;
@ApiPropertyOptional({ description: 'Filter sites that have candidates', default: false })
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
......@@ -77,13 +77,18 @@ export class FindSitesDto {
})
withCandidates?: boolean;
@ApiPropertyOptional({ description: 'Order by field (e.g., siteName, siteCode, address, city, state, country)' })
@ApiProperty({ required: false })
@IsOptional()
@IsString()
orderBy?: string;
@ApiPropertyOptional({ description: 'Order direction (asc or desc)', enum: OrderDirection })
@ApiProperty({ required: false, enum: OrderDirection })
@IsOptional()
@IsEnum(OrderDirection)
orderDirection?: OrderDirection;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isReported?: boolean;
}
\ No newline at end of file
......@@ -46,6 +46,9 @@ export class SiteResponseDto {
@ApiProperty({ description: 'Whether the site is a Digi site', default: false })
isDigi: boolean;
@ApiProperty({ description: 'Whether the site is reported', default: false })
isReported: boolean;
@ApiProperty({ description: 'Creation timestamp' })
createdAt: Date;
......
import { PartialType } from '@nestjs/swagger';
import { CreateSiteDto } from './create-site.dto';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNumber, IsOptional, IsBoolean, IsArray } from 'class-validator';
import { CompanyName } from '@prisma/client';
export class UpdateSiteDto extends PartialType(CreateSiteDto) { }
\ No newline at end of file
export class UpdateSiteDto {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
siteCode?: string;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
siteName?: string;
@ApiProperty({ required: false })
@IsNumber()
@IsOptional()
latitude?: number;
@ApiProperty({ required: false })
@IsNumber()
@IsOptional()
longitude?: number;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
type?: string;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isDigi?: boolean;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isReported?: boolean;
@ApiProperty({ required: false, enum: CompanyName, isArray: true })
@IsArray()
@IsOptional()
companies?: CompanyName[];
}
\ No newline at end of file
......@@ -9,6 +9,7 @@ import {
ParseIntPipe,
UseGuards,
Query,
Request,
} from '@nestjs/common';
import { SitesService } from './sites.service';
import { CreateSiteDto } from './dto/create-site.dto';
......@@ -18,6 +19,8 @@ import {
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
......@@ -26,6 +29,7 @@ import { Role } from '@prisma/client';
import { User } from '../auth/decorators/user.decorator';
import { FindSitesDto } from './dto/find-sites.dto';
import { SiteResponseDto } from './dto/site-response.dto';
import { Partner } from '../auth/decorators/partner.decorator';
@ApiTags('sites')
@Controller('sites')
......@@ -63,6 +67,8 @@ export class SitesController {
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.MANAGER, Role.SUPERADMIN, Role.PARTNER)
@ApiOperation({
summary: 'Get all sites for list view (with pagination)',
description: 'Returns paginated sites with applied filters and ordering. Use withCandidates=true to filter sites that have candidates. You can filter by type, siteCode, siteName, address, city, state, country, and isDigi status (true/false).'
......@@ -88,8 +94,12 @@ export class SitesController {
}
}
})
findAll(@Query() findSitesDto: FindSitesDto) {
return this.sitesService.findAll(findSitesDto);
async findAll(
@Query() findSitesDto: FindSitesDto,
@Request() req,
) {
const partnerId = req.user.role === Role.PARTNER ? req.user.partnerId : null;
return this.sitesService.findAll(findSitesDto, partnerId);
}
@Get('code/:siteCode')
......@@ -112,21 +122,46 @@ export class SitesController {
type: SiteResponseDto
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOne(@Param('id', ParseIntPipe) id: number) {
findOne(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role
) {
// For PARTNER role, we restrict access to only see sites associated with their partnerId
if (role === Role.PARTNER) {
return this.sitesService.findOneFilteredByPartner(id, partnerId);
}
return this.sitesService.findOne(id);
}
@Get(':id/with-candidates')
@ApiOperation({ summary: 'Get a site with its candidates' })
@ApiQuery({
name: 'partnerId',
required: false,
type: Number,
description: 'Filter candidates by specific partner ID'
})
@ApiResponse({
status: 200,
description: 'Return the site with its candidates.',
type: SiteResponseDto
})
@ApiResponse({ status: 404, description: 'Site not found.' })
findOneWithCandidates(@Param('id', ParseIntPipe) id: number) {
return this.sitesService.findOneWithCandidates(id);
findOneWithCandidates(
@Param('id', ParseIntPipe) id: number,
@Partner() partnerId: number | null,
@User('role') role: Role,
@Query('partnerId') filterPartnerId?: number
) {
// For PARTNER role, we restrict access to only see candidates created with their partnerId
if (role === Role.PARTNER) {
return this.sitesService.findOneWithCandidates(id, partnerId);
}
// If a specific partnerId is provided in the query, use that for filtering
return this.sitesService.findOneWithCandidates(id, filterPartnerId);
}
@Patch(':id')
@Roles(Role.SUPERADMIN, Role.ADMIN, Role.MANAGER)
......
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
import { PrismaClient, Prisma, CompanyName } from '@prisma/client';
import { CreateSiteDto } from './dto/create-site.dto';
import { UpdateSiteDto } from './dto/update-site.dto';
......@@ -10,7 +10,8 @@ export enum CandidateStatusPriority {
NEGOTIATION_ONGOING = 2,
MNO_VALIDATION = 3,
CLOSING = 4,
APPROVED = 5,
PAM = 5,
APPROVED = 6,
}
@Injectable()
......@@ -76,7 +77,7 @@ export class SitesService {
}
}
async findAll(findSitesDto: FindSitesDto) {
async findAll(findSitesDto: FindSitesDto, partnerId?: number | null) {
const {
page = 1,
limit = 10,
......@@ -91,6 +92,7 @@ export class SitesService {
withCandidates,
type,
isDigi,
isReported,
} = findSitesDto;
const skip = (page - 1) * limit;
......@@ -104,8 +106,10 @@ export class SitesService {
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
...(isReported !== undefined && { isReported }),
};
// Only filter by candidates if withCandidates is true
if (withCandidates === true) {
where.candidates = {
some: {},
......@@ -124,26 +128,33 @@ export class SitesService {
candidates: true,
},
},
candidates: withCandidates ? {
candidates: {
where: partnerId ? {
candidate: {
partnerId: partnerId
}
} : undefined,
include: {
candidate: true,
candidate: {
select: {
currentStatus: true
}
}
}
},
} : undefined,
},
}),
this.prisma.site.count({ where }),
]);
// Add highest priority status to sites with candidates
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map(site => {
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus: this.getHighestPriorityStatus(site.candidates),
candidates: undefined, // Remove candidates to avoid sending large data
highestCandidateStatus,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
}
return site;
});
return {
......@@ -221,11 +232,34 @@ export class SitesService {
return site;
}
async findOneWithCandidates(id: number) {
async findOneFilteredByPartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
}
const site = await this.prisma.site.findUnique({
where: { id },
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
candidates: {
where: {
candidate: {
partnerId: partnerId
}
},
include: {
candidate: {
include: {
......@@ -243,6 +277,67 @@ export class SitesService {
email: true,
},
},
},
},
},
},
_count: {
select: {
candidates: true,
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Add highest priority status if the site has candidates
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus,
};
}
return site;
}
async findOneWithCandidates(id: number, partnerId?: number | null) {
const site = await this.prisma.site.findUnique({
where: { id },
include: {
candidates: {
where: partnerId ? {
candidate: {
partnerId: partnerId
}
} : undefined,
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
partner: {
select: {
id: true,
name: true,
},
},
comments: {
take: 1,
include: {
......@@ -281,6 +376,79 @@ export class SitesService {
return site;
}
async findOneWithCandidatesFilteredByPartner(id: number, partnerId: number | null) {
if (!partnerId) {
throw new ForbiddenException('User does not have access to any partner resources');
}
// First, fetch the site with all candidates
const site = await this.prisma.site.findUnique({
where: { id },
include: {
candidates: {
include: {
candidate: {
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
updatedBy: {
select: {
id: true,
name: true,
email: true,
},
},
comments: {
take: 1,
include: {
createdBy: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
},
},
},
},
});
if (!site) {
throw new NotFoundException(`Site with ID ${id} not found`);
}
// Filter the candidates to only include those with the partner's ID
const filteredSite = {
...site,
candidates: site.candidates.filter(candidateSite =>
candidateSite.candidate.partnerId === partnerId
)
};
// Add highest priority status if the site has filtered candidates
if (filteredSite.candidates && filteredSite.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(filteredSite.candidates);
return {
...filteredSite,
highestCandidateStatus,
};
}
return filteredSite;
}
async update(id: number, updateSiteDto: UpdateSiteDto, userId: number) {
try {
return await this.prisma.site.update({
......@@ -292,6 +460,7 @@ export class SitesService {
longitude: updateSiteDto.longitude,
type: updateSiteDto.type,
isDigi: updateSiteDto.isDigi,
isReported: updateSiteDto.isReported,
companies: updateSiteDto.companies !== undefined ? updateSiteDto.companies : undefined,
updatedById: userId,
},
......@@ -414,6 +583,7 @@ export class SitesService {
withCandidates,
type,
isDigi,
isReported,
} = findSitesDto;
const where: Prisma.SiteWhereInput = {
......@@ -425,13 +595,11 @@ export class SitesService {
...(country && { country: { contains: country, mode: Prisma.QueryMode.insensitive } }),
...(type && { type: { contains: type, mode: Prisma.QueryMode.insensitive } }),
...(isDigi !== undefined && { isDigi }),
};
if (withCandidates === true) {
where.candidates = {
...(isReported !== undefined && { isReported }),
candidates: {
some: {},
};
}
};
const sites = await this.prisma.site.findMany({
where,
......@@ -442,24 +610,26 @@ export class SitesService {
candidates: true,
},
},
candidates: withCandidates ? {
candidates: {
include: {
candidate: true,
candidate: {
select: {
currentStatus: true
}
}
}
},
} : undefined,
},
});
// Add highest priority status to sites with candidates
// Add highest priority status to all sites
const sitesWithHighestStatus = sites.map(site => {
if (site.candidates && site.candidates.length > 0) {
const highestCandidateStatus = this.getHighestPriorityStatus(site.candidates);
return {
...site,
highestCandidateStatus: this.getHighestPriorityStatus(site.candidates),
candidates: undefined, // Remove candidates to avoid sending large data
highestCandidateStatus,
candidates: withCandidates ? site.candidates : undefined, // Only include full candidates data if withCandidates is true
};
}
return site;
});
return sitesWithHighestStatus;
......
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, IsInt, Min, IsEnum } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export enum UserOrderBy {
NAME_ASC = 'name_asc',
NAME_DESC = 'name_desc',
EMAIL_ASC = 'email_asc',
EMAIL_DESC = 'email_desc',
CREATED_AT_ASC = 'createdAt_asc',
CREATED_AT_DESC = 'createdAt_desc',
ROLE_ASC = 'role_asc',
ROLE_DESC = 'role_desc',
}
export class FindUsersDto {
@ApiProperty({
......@@ -30,4 +41,48 @@ export class FindUsersDto {
return value;
})
unassignedPartners?: boolean;
@ApiProperty({
description: 'Page number for pagination (starts at 1)',
example: 1,
required: false,
default: 1,
})
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1;
@ApiProperty({
description: 'Number of items per page',
example: 10,
required: false,
default: 10,
})
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 10;
@ApiProperty({
description: 'Search term to filter users by name or email',
example: 'john',
required: false,
})
@IsOptional()
@IsString()
search?: string;
@ApiProperty({
description: 'Order by field and direction',
enum: UserOrderBy,
example: UserOrderBy.NAME_ASC,
required: false,
default: UserOrderBy.NAME_ASC,
})
@IsOptional()
@IsEnum(UserOrderBy)
orderBy?: UserOrderBy = UserOrderBy.NAME_ASC;
}
\ No newline at end of file
......@@ -13,7 +13,7 @@ import {
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-users.dto';
import { FindUsersDto, UserOrderBy } from './dto/find-users.dto';
import {
ApiTags,
ApiOperation,
......@@ -50,7 +50,7 @@ export class UsersController {
@ApiOperation({ summary: 'Get all users' })
@ApiResponse({
status: 200,
description: 'Return all users.',
description: 'Return all users with pagination.',
})
@ApiQuery({
name: 'active',
......@@ -64,6 +64,30 @@ export class UsersController {
type: Boolean,
description: 'Get only active users with PARTNER role who don\'t have a partner assigned (true/false)',
})
@ApiQuery({
name: 'page',
required: false,
type: Number,
description: 'Page number for pagination (starts at 1)',
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Number of items per page',
})
@ApiQuery({
name: 'search',
required: false,
type: String,
description: 'Search term to filter users by name or email',
})
@ApiQuery({
name: 'orderBy',
required: false,
enum: UserOrderBy,
description: 'Order by field and direction',
})
findAll(@Query() query: FindUsersDto) {
return this.usersService.findAll(query);
}
......
......@@ -32,7 +32,11 @@ export class UsersService {
}
async findAll(query?: FindUsersDto) {
const where = {};
let where: any = {
NOT: {
role: Role.SUPERADMIN
}
};
if (query?.active !== undefined) {
where['isActive'] = query.active;
......@@ -40,13 +44,57 @@ export class UsersService {
// If unassignedPartners is true, filter for active users with PARTNER role and no partner
if (query?.unassignedPartners === true) {
where['isActive'] = true;
where['role'] = Role.PARTNER;
where['partnerId'] = null;
// Create a new where object for partner-specific query
where = {
isActive: true,
role: Role.PARTNER,
partnerId: null
};
}
return this.prisma.user.findMany({
// Add search functionality for name or email
if (query?.search) {
where.OR = [
{
name: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
{
email: {
contains: query.search,
mode: 'insensitive', // Case insensitive search
},
},
];
}
// Set up pagination
const page = query?.page || 1;
const limit = query?.limit || 10;
const skip = (page - 1) * limit;
// Set up ordering
let orderBy: any = { name: 'asc' }; // Default ordering
if (query?.orderBy) {
const [field, direction] = query.orderBy.split('_');
// Special handling for role field if needed
orderBy = { [field]: direction };
}
// Get total count for pagination metadata
const totalCount = await this.prisma.user.count({ where });
// Fetch users with pagination and ordering
const users = await this.prisma.user.findMany({
where,
skip,
take: limit,
orderBy,
select: {
id: true,
email: true,
......@@ -58,6 +106,17 @@ export class UsersService {
partnerId: true,
},
});
// Return users with pagination metadata
return {
data: users,
meta: {
totalCount,
page,
limit,
totalPages: Math.ceil(totalCount / limit),
},
};
}
async findOne(id: number) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment