diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts new file mode 100644 index 00000000..722ace34 --- /dev/null +++ b/src/admin/admin.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { UserRole } from '../types/prisma.types'; +import { AdminService } from './admin.service'; +import { + AdminUpdateUserDto, + AdminUsersQueryDto, + BulkModerationDto, + FlagPropertyDto, + ModerationQueueQueryDto, + TransactionMonitoringQueryDto, +} from './dto/admin.dto'; + +@Controller('admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @Get('dashboard') + getDashboard() { + return this.adminService.getDashboard(); + } + + @Get('users') + listUsers(@Query() query: AdminUsersQueryDto) { + return this.adminService.listUsers(query); + } + + @Patch('users/:id') + updateUser(@Param('id') userId: string, @Body() payload: AdminUpdateUserDto) { + return this.adminService.updateUser(userId, payload); + } + + @Post('users/:id/block') + blockUser(@Param('id') userId: string) { + return this.adminService.setUserBlockedState(userId, true); + } + + @Post('users/:id/unblock') + unblockUser(@Param('id') userId: string) { + return this.adminService.setUserBlockedState(userId, false); + } + + @Get('properties/moderation/queue') + getModerationQueue(@Query() query: ModerationQueueQueryDto) { + return this.adminService.getModerationQueue(query); + } + + @Post('properties/:id/approve') + approveProperty(@Param('id') propertyId: string) { + return this.adminService.approveProperty(propertyId); + } + + @Post('properties/:id/reject') + rejectProperty(@Param('id') propertyId: string) { + return this.adminService.rejectProperty(propertyId); + } + + @Post('properties/:id/flag') + flagProperty(@Param('id') propertyId: string, @Body() body: FlagPropertyDto) { + return this.adminService.flagProperty(propertyId, body.reason); + } + + @Post('properties/moderation/bulk') + bulkModerate(@Body() body: BulkModerationDto, @CurrentUser() _user: AuthUserPayload) { + return this.adminService.bulkModerate(body); + } + + @Get('transactions/monitoring') + monitorTransactions(@Query() query: TransactionMonitoringQueryDto) { + return this.adminService.monitorTransactions(query); + } + + @Get('transactions/monitoring/summary') + monitorTransactionsSummary() { + return this.adminService.transactionMonitoringSummary(); + } +} diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts new file mode 100644 index 00000000..66fe04ce --- /dev/null +++ b/src/admin/admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { PrismaModule } from '../database/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts new file mode 100644 index 00000000..a25f684c --- /dev/null +++ b/src/admin/admin.service.ts @@ -0,0 +1,286 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { + AdminUpdateUserDto, + AdminUsersQueryDto, + BulkModerationAction, + BulkModerationDto, + ModerationQueueQueryDto, + TransactionMonitoringQueryDto, +} from './dto/admin.dto'; +import { PropertyStatus } from '../types/prisma.types'; + +@Injectable() +export class AdminService { + constructor(private readonly prisma: PrismaService) {} + + async getDashboard() { + const [totalUsers, blockedUsers, totalProperties, pendingProperties, activeProperties] = + await Promise.all([ + this.prisma.user.count(), + this.prisma.user.count({ where: { isBlocked: true } }), + this.prisma.property.count(), + this.prisma.property.count({ where: { status: PropertyStatus.PENDING } }), + this.prisma.property.count({ where: { status: PropertyStatus.ACTIVE } }), + ]); + + const [completedTransactions, pendingTransactions, failedTransactions, salesAggregate, rentAggregate] = + await Promise.all([ + this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), + this.prisma.transaction.count({ where: { status: 'PENDING' } }), + this.prisma.transaction.count({ where: { status: 'FAILED' } }), + this.prisma.transaction.aggregate({ + where: { status: 'COMPLETED', type: 'SALE' }, + _sum: { amount: true }, + }), + this.prisma.transaction.aggregate({ + where: { status: 'COMPLETED', type: 'TRANSFER' }, + _sum: { amount: true }, + }), + ]); + + return { + userStats: { + totalUsers, + blockedUsers, + activeUsers: totalUsers - blockedUsers, + }, + propertyStats: { + totalProperties, + pendingProperties, + activeProperties, + }, + revenueMetrics: { + totalSalesRevenue: salesAggregate._sum.amount ?? 0, + totalTransferRevenue: rentAggregate._sum.amount ?? 0, + }, + systemHealth: { + completedTransactions, + pendingTransactions, + failedTransactions, + }, + }; + } + + async listUsers(query: AdminUsersQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const where = { + role: query.role, + OR: query.search + ? [ + { email: { contains: query.search, mode: 'insensitive' as const } }, + { firstName: { contains: query.search, mode: 'insensitive' as const } }, + { lastName: { contains: query.search, mode: 'insensitive' as const } }, + ] + : undefined, + }; + + const [items, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isVerified: true, + isBlocked: true, + createdAt: true, + }, + }), + this.prisma.user.count({ where }), + ]); + + return { total, page, limit, items }; + } + + async updateUser(userId: string, payload: AdminUpdateUserDto) { + return this.prisma.user.update({ + where: { id: userId }, + data: payload, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + phone: true, + role: true, + isVerified: true, + isBlocked: true, + updatedAt: true, + }, + }); + } + + async setUserBlockedState(userId: string, blocked: boolean) { + return this.prisma.user.update({ + where: { id: userId }, + data: { isBlocked: blocked }, + select: { + id: true, + email: true, + isBlocked: true, + }, + }); + } + + async getModerationQueue(query: ModerationQueueQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const where = { + status: query.status ?? PropertyStatus.PENDING, + }; + + const [items, total] = await Promise.all([ + this.prisma.property.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'asc' }, + include: { + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.property.count({ where }), + ]); + + return { total, page, limit, items }; + } + + async approveProperty(propertyId: string) { + return this.prisma.property.update({ + where: { id: propertyId }, + data: { status: PropertyStatus.ACTIVE }, + }); + } + + async rejectProperty(propertyId: string) { + return this.prisma.property.update({ + where: { id: propertyId }, + data: { status: PropertyStatus.ARCHIVED }, + }); + } + + async flagProperty(propertyId: string, reason?: string) { + const property = await this.prisma.property.update({ + where: { id: propertyId }, + data: { status: PropertyStatus.ARCHIVED }, + }); + + await this.prisma.activityLog.create({ + data: { + userId: property.ownerId, + action: 'PROPERTY_FLAGGED_BY_ADMIN', + entityType: 'PROPERTY', + entityId: property.id, + description: reason ?? 'Property flagged by admin for moderation review.', + }, + }); + + return property; + } + + async bulkModerate(payload: BulkModerationDto) { + const status = + payload.action === BulkModerationAction.APPROVE ? PropertyStatus.ACTIVE : PropertyStatus.ARCHIVED; + + const result = await this.prisma.property.updateMany({ + where: { id: { in: payload.propertyIds } }, + data: { status }, + }); + + if (payload.action === BulkModerationAction.FLAG) { + const properties = await this.prisma.property.findMany({ + where: { id: { in: payload.propertyIds } }, + select: { id: true, ownerId: true }, + }); + + await this.prisma.activityLog.createMany({ + data: properties.map((property) => ({ + userId: property.ownerId, + action: 'PROPERTY_FLAGGED_BY_ADMIN', + entityType: 'PROPERTY', + entityId: property.id, + description: payload.reason ?? 'Property flagged by admin via bulk moderation.', + })), + }); + } + + return { + updatedCount: result.count, + action: payload.action, + }; + } + + async monitorTransactions(query: TransactionMonitoringQueryDto) { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const where = { + status: query.status, + type: query.type, + propertyId: query.propertyId, + }; + + const [items, total] = await Promise.all([ + this.prisma.transaction.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + property: { + select: { id: true, title: true, address: true }, + }, + buyer: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + seller: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + }, + }), + this.prisma.transaction.count({ where }), + ]); + + return { total, page, limit, items }; + } + + async transactionMonitoringSummary() { + const [pending, completed, cancelled, failed, aggregateValue] = await Promise.all([ + this.prisma.transaction.count({ where: { status: 'PENDING' } }), + this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), + this.prisma.transaction.count({ where: { status: 'CANCELLED' } }), + this.prisma.transaction.count({ where: { status: 'FAILED' } }), + this.prisma.transaction.aggregate({ + where: { status: 'COMPLETED' }, + _sum: { amount: true }, + }), + ]); + + return { + pending, + completed, + cancelled, + failed, + totalCompletedValue: aggregateValue._sum.amount ?? 0, + }; + } +} diff --git a/src/admin/dto/admin.dto.ts b/src/admin/dto/admin.dto.ts new file mode 100644 index 00000000..6eb8a9b0 --- /dev/null +++ b/src/admin/dto/admin.dto.ts @@ -0,0 +1,119 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { PropertyStatus, TransactionStatus, TransactionType, UserRole } from '../../types/prisma.types'; + +export class AdminUsersQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} + +export class AdminUpdateUserDto { + @IsOptional() + @IsString() + firstName?: string; + + @IsOptional() + @IsString() + lastName?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + isVerified?: boolean; +} + +export class ModerationQueueQueryDto { + @IsOptional() + @IsEnum(PropertyStatus) + status?: PropertyStatus; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} + +export class FlagPropertyDto { + @IsOptional() + @IsString() + reason?: string; +} + +export enum BulkModerationAction { + APPROVE = 'APPROVE', + REJECT = 'REJECT', + FLAG = 'FLAG', +} + +export class BulkModerationDto { + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + propertyIds!: string[]; + + @IsEnum(BulkModerationAction) + action!: BulkModerationAction; + + @IsOptional() + @IsString() + reason?: string; +} + +export class TransactionMonitoringQueryDto { + @IsOptional() + @IsEnum(TransactionStatus) + status?: TransactionStatus; + + @IsOptional() + @IsEnum(TransactionType) + type?: TransactionType; + + @IsOptional() + @IsUUID('4') + propertyId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/app.module.ts b/src/app.module.ts index 258f2230..d7d38c4d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { VersioningModule } from './versioning/versioning.module'; import { ApiDocumentationModule } from './config/api-documentation.module'; import { CacheModuleConfig } from './cache/cache.module'; import { AppController } from './app.controller'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { AppController } from './app.controller'; SessionsModule, TrustScoreModule, PropertiesModule, + AdminModule, ], controllers: [AppController], }) diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index ec923961..106e3d25 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -58,6 +58,29 @@ export enum UserRole { AGENT = 'AGENT', } +export enum PropertyStatus { + DRAFT = 'DRAFT', + PENDING = 'PENDING', + ACTIVE = 'ACTIVE', + UNDER_CONTRACT = 'UNDER_CONTRACT', + SOLD = 'SOLD', + RENTED = 'RENTED', + ARCHIVED = 'ARCHIVED', +} + +export enum TransactionType { + SALE = 'SALE', + PURCHASE = 'PURCHASE', + TRANSFER = 'TRANSFER', +} + +export enum TransactionStatus { + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + FAILED = 'FAILED', +} + export namespace Prisma { export interface PropertyWhereInput extends Record {} export interface PropertyOrderByWithRelationInput extends Record {}